Compare commits

..

4 Commits

Author SHA1 Message Date
CyberMind
6aef15bdf7
Merge pull request #715 from CyberMind-FR/feature/714-kbin-pdf-charts-blank-in-some-viewers-re
Some checks are pending
License Headers / check (push) Waiting to run
PDF: render charts as matplotlib PNGs — fix blank/broken graphs in viewers (closes #714)
2026-06-22 12:34:32 +02:00
ddfe6a7a74 fix(toolbox): render PDF charts as matplotlib PNGs — fix blank/broken graphs in viewers (closes #714)
fpdf2 vector arc/ellipse donuts + carto rendered in poppler but came out blank
in iOS/Chrome PDF viewers (reported: "page 2 KO, dpi", pages blanches, erreurs
de graphs). Render all charts with matplotlib (Agg) to PNG and embed via
pdf.image() — raster displays in every viewer.

- _mpl_donut_png: real donut ring (wedgeprops width) + centre label; _pdf_donut
  embeds it + keeps the fpdf2 text legend.
- _mpl_carto_png: matplotlib hub graph (TOI centre, nodes sized by hits, ISO +
  domain labels, spokes); _carto_graph embeds it.
- bars/tables stay as fpdf2 filled rects + text (render everywhere).
- fix #701 _donut_lines: reset X so section titles aren't clipped at the right.

Verified on gk2 (poppler): page 1 "En un coup d'œil" donut ring + bars; page 2
DPI/MITM/Certs/Pubs rings + carto hub render cleanly as images.
2026-06-22 12:34:25 +02:00
CyberMind
2029611010
Merge pull request #713 from CyberMind-FR/feature/711-kbin-pdf-donut-charts-render-as-solid-pi
PDF: ring donuts + 'En un coup d'œil' section (closes #711, #712)
2026-06-22 12:23:24 +02:00
a2e342cfd2 fix(toolbox): donut charts as true rings + add "En un coup d'œil" to the PDF (closes #711, closes #712)
#711 — _pdf_donut drew filled sectors + a white hole, which read as a solid pie.
Redraw each segment as a THICK STROKED arc (pdf.arc, line_width = band width) on
a faint full-ring underlay → a real concentric ring/annulus.

#712 — _glance_section + _bars render the HTML report's "📊 En un coup d'œil"
card in the PDF: trackers ring + "Vers quels pays" + "Où tu es le plus pisté"
horizontal bars (from report charts). api.py passes charts + graph_stats.

Verified on gk2: page 1 shows the multi-segment trackers ring + the two bar
groups; the 4 device donuts (DPI/MITM/Certs/Pubs) render as rings too.
2026-06-22 12:23:17 +02:00
2 changed files with 147 additions and 58 deletions

View File

@ -2734,7 +2734,10 @@ async def report_me(request: Request) -> Response:
data["persona"] = _persona_sheet(mac_hash, _lvl, _gs, _exp, data["dpi_exfil"], data["persona"] = _persona_sheet(mac_hash, _lvl, _gs, _exp, data["dpi_exfil"],
data.get("device_type", ""), data.get("device_type", ""),
request.headers.get("user-agent", "")) request.headers.get("user-agent", ""))
data["bestiary"] = (_build_report_charts(_graph).get("trackers") or [])[:5] _charts = _build_report_charts(_graph)
data["charts"] = _charts # #711 "En un coup d'œil"
data["graph_stats"] = _gs
data["bestiary"] = (_charts.get("trackers") or [])[:5]
data["carto_nodes"] = _graph.get("nodes") or [] # #709 carto + tables data["carto_nodes"] = _graph.get("nodes") or [] # #709 carto + tables
data["carto_country"] = _graph.get("by_country") or [] data["carto_country"] = _graph.get("by_country") or []
pdf_bytes = reports.render_pdf(data) pdf_bytes = reports.render_pdf(data)

View File

@ -149,6 +149,10 @@ def render_pdf(report: dict) -> bytes:
else: else:
_dashboard_hero(pdf, family, report) _dashboard_hero(pdf, family, report)
# ── #711 "En un coup d'œil" : trackers ring + countries/sites bars ──
_glance_section(pdf, family, report.get("charts") or {},
int((report.get("graph_stats") or {}).get("total_trackers", 0) or 0))
# Anonymous ID # Anonymous ID
_section(pdf, "🔑 IDENTIFIANT ANONYME") _section(pdf, "🔑 IDENTIFIANT ANONYME")
_kv(pdf, "Hash session", report.get("mac_hash", "?")) _kv(pdf, "Hash session", report.get("mac_hash", "?"))
@ -258,7 +262,9 @@ def render_pdf(report: dict) -> bytes:
def _donut_lines(title: str, items: list) -> None: def _donut_lines(title: str, items: list) -> None:
if not items: if not items:
return return
pdf.set_x(pdf.l_margin) # #714 reset X so the title isn't clipped right
pdf.set_font(getattr(pdf, "_secubox_family", "Helvetica"), "B", 9) pdf.set_font(getattr(pdf, "_secubox_family", "Helvetica"), "B", 9)
pdf.set_text_color(0)
pdf.cell(0, 5, _ascii_safe(title), ln=True) pdf.cell(0, 5, _ascii_safe(title), ln=True)
for it in items: for it in items:
_bullet(pdf, f"{it.get('emoji', '')} {it.get('label', '?')} - {it.get('pct', 0)}%", font_size=8) _bullet(pdf, f"{it.get('emoji', '')} {it.get('label', '?')} - {it.get('pct', 0)}%", font_size=8)
@ -728,49 +734,68 @@ def _bullet(pdf, text: str, font_size: int = 9) -> None:
pdf.multi_cell(_page_w(pdf), 5, " - " + safe) pdf.multi_cell(_page_w(pdf), 5, " - " + safe)
# #703 — visual donut charts in the PDF (fpdf2 solid_arc sectors + white hole). # #703/#711/#714 — charts are rendered with matplotlib to PNG and EMBEDDED as
# RGB mirror of the HTML report palette. # raster images (pdf.image), because fpdf2 vector arc/ellipse donuts render in
# poppler but blank in iOS/Chrome PDF viewers. Raster PNG displays everywhere.
_PDF_DONUT_PALETTE = [ _PDF_DONUT_PALETTE = [
(0, 221, 68), (158, 118, 255), (255, 136, 102), (0, 221, 68), (158, 118, 255), (255, 136, 102),
(102, 187, 255), (255, 179, 71), (255, 68, 102), (102, 187, 255), (255, 179, 71), (255, 68, 102),
] ]
_PDF_HEX = ["#%02x%02x%02x" % c for c in _PDF_DONUT_PALETTE]
def _mpl_donut_png(segs: list, hole: str = ""):
"""Render a donut ring (matplotlib) to a PNG BytesIO. None if no data/mpl."""
try:
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
from io import BytesIO
except Exception:
return None
vals, cols = [], []
for i, s in enumerate(segs or []):
v = s.get("pct") or s.get("count") or 0
if v:
vals.append(v)
cols.append(_PDF_HEX[i % len(_PDF_HEX)])
if not vals:
return None
fig, ax = plt.subplots(figsize=(1.7, 1.7), dpi=130)
ax.pie(vals, colors=cols, startangle=90, counterclock=False,
wedgeprops=dict(width=0.42, edgecolor="white", linewidth=1.2))
if hole:
ax.text(0, 0, str(hole)[:8], ha="center", va="center",
fontsize=9, color="#444", weight="bold")
ax.set(aspect="equal")
buf = BytesIO()
fig.savefig(buf, format="png", transparent=True, bbox_inches="tight", pad_inches=0.02)
plt.close(fig)
buf.seek(0)
return buf
def _pdf_donut(pdf, x: float, y: float, w: float, title: str, hole: str, segs: list) -> None: def _pdf_donut(pdf, x: float, y: float, w: float, title: str, hole: str, segs: list) -> None:
"""Draw one donut (pie sectors + white centre hole) + legend inside a cell of """Title + embedded donut PNG (left) + text legend (right) in a cell of width w."""
width w at (x, y). segs carry cumulative start/end percents (from _dpi_donut)."""
fam = getattr(pdf, "_secubox_family", "Helvetica") fam = getattr(pdf, "_secubox_family", "Helvetica")
pdf.set_xy(x, y) pdf.set_xy(x, y)
pdf.set_font(fam, "B", 9) pdf.set_font(fam, "B", 9)
pdf.set_text_color(0, 90, 64) pdf.set_text_color(0, 90, 64)
pdf.cell(w, 5, _ascii_safe(title)[:30], ln=False) pdf.cell(w, 5, _ascii_safe(title)[:30], ln=False)
cx, cy, r, rh = x + 15, y + 23, 12.5, 8.0 png = _mpl_donut_png(segs, hole) if segs else None
if segs: if png is not None:
for i, s in enumerate(segs): try:
pdf.set_fill_color(*_PDF_DONUT_PALETTE[i % len(_PDF_DONUT_PALETTE)]) pdf.image(png, x=x, y=y + 6, w=28, h=28)
a0 = 90.0 - float(s.get("start", 0)) * 3.6 except Exception:
a1 = 90.0 - float(s.get("end", 0)) * 3.6 pass
try:
pdf.solid_arc(cx, cy, r, a0, a1, clockwise=True, style="F")
except Exception:
pass
# centre hole (page is white)
pdf.set_fill_color(255, 255, 255)
pdf.ellipse(cx - rh, cy - rh, 2 * rh, 2 * rh, style="F")
if hole:
pdf.set_xy(cx - rh, cy - 2)
pdf.set_font(fam, "", 6)
pdf.set_text_color(110, 110, 110)
pdf.cell(2 * rh, 4, _ascii_safe(hole)[:8], align="C")
# legend (right of the donut)
ly = y + 8 ly = y + 8
for i, s in enumerate(segs[:6]): for i, s in enumerate(segs[:6]):
pdf.set_fill_color(*_PDF_DONUT_PALETTE[i % len(_PDF_DONUT_PALETTE)]) pdf.set_fill_color(*_PDF_DONUT_PALETTE[i % len(_PDF_DONUT_PALETTE)])
pdf.rect(x + 33, ly + 0.6, 2.4, 2.4, style="F") pdf.rect(x + 31, ly + 0.6, 2.4, 2.4, style="F")
pdf.set_xy(x + 37, ly) pdf.set_xy(x + 35, ly)
pdf.set_font(fam, "", 7) pdf.set_font(fam, "", 7)
pdf.set_text_color(40, 40, 40) pdf.set_text_color(40, 40, 40)
pdf.cell(w - 38, 3.5, pdf.cell(w - 36, 3.5,
_ascii_safe(f"{s.get('label', '?')[:16]} {s.get('pct', 0)}%"), ln=False) _ascii_safe(f"{s.get('label', '?')[:16]} {s.get('pct', 0)}%"), ln=False)
ly += 4 ly += 4
else: else:
@ -798,43 +823,104 @@ def _pdf_donut_grid(pdf, donuts: list) -> None:
pdf.set_y(y0 + rows * row_h + 2) pdf.set_y(y0 + rows * row_h + 2)
# #709 — radial "carto" network map (TOI hub → top trackers) for the PDF. def _bars(pdf, family: str, title: str, rows: list, col: tuple = (0, 221, 68)) -> None:
def _carto_graph(pdf, family: str, nodes: list) -> None: """Horizontal percent bars (label · bar · count). rows = [(label, pct, count)]."""
import math rows = [r for r in (rows or []) if r]
if not rows:
return
pdf.set_x(pdf.l_margin)
pdf.set_font(family, "B", 9)
pdf.set_text_color(110, 64, 201)
pdf.cell(0, 5, _safe(title), ln=True)
w = _page_w(pdf)
lblw, valw = 40.0, 13.0
barw = w - lblw - valw
pdf.set_font(family, "", 8)
for (lbl, pct, cnt) in rows[:6]:
pct = max(0, min(100, int(pct or 0)))
pdf.set_x(pdf.l_margin)
pdf.set_text_color(40, 40, 40)
pdf.cell(lblw, 4.6, _safe(str(lbl))[:22], ln=False)
bx, by = pdf.get_x(), pdf.get_y()
pdf.set_fill_color(234, 236, 240)
pdf.rect(bx, by + 0.9, barw, 2.8, style="F")
pdf.set_fill_color(*col)
pdf.rect(bx, by + 0.9, barw * pct / 100.0, 2.8, style="F")
pdf.set_xy(bx + barw + 1, by)
pdf.set_text_color(0, 150, 60)
pdf.cell(valw, 4.6, str(cnt), ln=True)
pdf.ln(1)
def _glance_section(pdf, family: str, charts: dict, n_trackers: int) -> None:
"""#711 — '📊 En un coup d'œil' for the PDF: trackers ring + countries +
most-tracked sites bars (same content as the HTML report card)."""
ch = charts or {}
if not (ch.get("trackers") or ch.get("countries") or ch.get("sites")):
return
_section(pdf, "📊 EN UN COUP D'ŒIL")
y0 = pdf.get_y()
_pdf_donut(pdf, pdf.l_margin, y0, _page_w(pdf), "🍪 Qui te trace",
str(n_trackers), ch.get("trackers") or [])
pdf.set_y(y0 + 38)
_bars(pdf, family, "🌍 Vers quels pays",
[(f"{c.get('flag', '')} {c.get('label', '?')}", c.get("pct", 0), c.get("count", 0))
for c in (ch.get("countries") or [])], col=(0, 221, 68))
_bars(pdf, family, "🌐 Où tu es le plus pisté (traceurs/site)",
[(s.get("label", "?"), s.get("pct", 0), s.get("count", 0))
for s in (ch.get("sites") or [])], col=(158, 118, 255))
# #709/#714 — radial "carto" hub map rendered with matplotlib → embedded PNG.
def _mpl_carto_png(nodes: list):
try:
import math
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
from io import BytesIO
except Exception:
return None
nodes = sorted([n for n in (nodes or []) if n.get("hits")], nodes = sorted([n for n in (nodes or []) if n.get("hits")],
key=lambda n: n.get("hits", 0), reverse=True)[:8] key=lambda n: n.get("hits", 0), reverse=True)[:8]
if not nodes: if not nodes:
return return None
_section(pdf, "🗺️ CARTO — qui te piste (carte du réseau)")
cx = pdf.l_margin + _page_w(pdf) / 2.0
cy = pdf.get_y() + 34
R = 27.0
maxh = max(n.get("hits", 1) for n in nodes) or 1 maxh = max(n.get("hits", 1) for n in nodes) or 1
pdf.set_draw_color(70, 90, 120) fig, ax = plt.subplots(figsize=(6.4, 3.4), dpi=130)
pdf.set_line_width(0.2)
placed = []
for i, n in enumerate(nodes): for i, n in enumerate(nodes):
ang = math.radians(-90 + i * 360.0 / len(nodes)) ang = math.radians(-90 + i * 360.0 / len(nodes))
x, y = cx + R * math.cos(ang), cy + R * math.sin(ang) x, y = math.cos(ang), math.sin(ang)
pdf.line(cx, cy, x, y) ax.plot([0, x], [0, y], color="#3a4a66", lw=0.8, zorder=1)
placed.append((x, y, n)) ax.scatter([x], [y], s=120 + 520 * (n.get("hits", 1) / maxh),
for (x, y, n) in placed: color="#ff506e", edgecolors="white", linewidths=0.8, zorder=2)
r = 1.6 + 3.2 * (n.get("hits", 1) / maxh) iso = (n.get("country_iso") or "").upper()
pdf.set_fill_color(255, 80, 110) lbl = f"{iso} {(n.get('domain','') or '?')[:16]}".strip()
pdf.ellipse(x - r, y - r, 2 * r, 2 * r, style="F") ax.text(x * 1.32, y * 1.32, lbl, fontsize=7, ha="center", va="center", color="#333")
lbl = f"{n.get('country_flag','')} {(n.get('domain','') or '?')[:12]}" ax.scatter([0], [0], s=900, color="#00d4ff", edgecolors="white", linewidths=1.2, zorder=3)
pdf.set_font(family, "", 6) ax.text(0, 0, "TOI", fontsize=8, ha="center", va="center", weight="bold", color="#06202a", zorder=4)
pdf.set_text_color(90, 90, 90) ax.set_xlim(-2.1, 2.1)
pdf.set_xy(x - 17, (y + r + 0.5) if y >= cy else (y - r - 3.5)) ax.set_ylim(-1.7, 1.7)
pdf.cell(34, 3, _safe(lbl), align="C") ax.axis("off")
pdf.set_fill_color(0, 212, 255) buf = BytesIO()
pdf.ellipse(cx - 4.5, cy - 4.5, 9, 9, style="F") fig.savefig(buf, format="png", transparent=True, bbox_inches="tight", pad_inches=0.05)
pdf.set_xy(cx - 9, cy - 1.6) plt.close(fig)
pdf.set_font(family, "B", 6) buf.seek(0)
pdf.set_text_color(10, 10, 15) return buf
pdf.cell(18, 3, "TOI", align="C")
pdf.set_text_color(0)
pdf.set_y(cy + R + 7) def _carto_graph(pdf, family: str, nodes: list) -> None:
png = _mpl_carto_png(nodes)
if png is None:
return
_section(pdf, "🗺️ CARTO — qui te piste (carte du réseau)")
w = _page_w(pdf)
h = w * 0.5
y0 = pdf.get_y()
try:
pdf.image(png, x=pdf.l_margin, y=y0, w=w, h=h)
except Exception:
return
pdf.set_y(y0 + h + 3)
# #709 — generic emoji data table. cols = [(header, width_fraction), ...] # #709 — generic emoji data table. cols = [(header, width_fraction), ...]