Fabio Antonini Claude Sonnet 4.6 commited on
Commit
21e9333
·
1 Parent(s): a579ad1

Add PDF report generation to Forensic Pipeline

Browse files

After the pipeline completes, a "Scarica Report PDF" button appears.
_generate_pipeline_pdf() uses fpdf2 to build a professional multi-page
forensic report with all 7 step outputs (text + embedded images) and
the LLM narrative conclusion, plus a disclaimer footer.
Unicode normalization via _to_latin1() handles em-dash and other
characters outside latin-1 range required by fpdf2 core fonts.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

Files changed (2) hide show
  1. app/grapholab_demo.py +193 -0
  2. requirements.txt +1 -0
app/grapholab_demo.py CHANGED
@@ -1306,6 +1306,177 @@ def run_pipeline(
1306
  yield (_, _, _, _, _, _, _, _, _, _, _, _, _, llm_report)
1307
 
1308
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1309
  with gr.Blocks() as pipeline_tab:
1310
  gr.Markdown(
1311
  "## Pipeline Forense Integrata\n\n"
@@ -1373,6 +1544,10 @@ with gr.Blocks() as pipeline_tab:
1373
  with gr.Accordion("Step 7 — Valutazione LLM (Ollama)", open=True):
1374
  out_llm = gr.Markdown(label="Referto sintetico LLM")
1375
 
 
 
 
 
1376
  pipe_btn.click(
1377
  fn=run_pipeline,
1378
  inputs=[pipe_doc, pipe_ref],
@@ -1387,6 +1562,24 @@ with gr.Blocks() as pipeline_tab:
1387
  pipe_results,
1388
  out_llm,
1389
  ],
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1390
  )
1391
 
1392
 
 
1306
  yield (_, _, _, _, _, _, _, _, _, _, _, _, _, llm_report)
1307
 
1308
 
1309
+ def _generate_pipeline_pdf(
1310
+ s1_img, s1_txt,
1311
+ s2_txt,
1312
+ s3_md,
1313
+ s4_md, s4_img,
1314
+ s5_md, s5_img,
1315
+ s6_txt, s6_img,
1316
+ llm_text,
1317
+ ) -> str:
1318
+ """Generate a PDF forensic report from pipeline outputs. Returns the file path."""
1319
+ import tempfile, re, unicodedata
1320
+ from fpdf import FPDF
1321
+ from PIL import Image as _PILImage
1322
+ import io as _io
1323
+ import datetime
1324
+
1325
+ def _to_latin1(text: str) -> str:
1326
+ """Normalize Unicode to latin-1 for fpdf2 core fonts."""
1327
+ if not text:
1328
+ return ""
1329
+ replacements = {
1330
+ "\u2014": "-", "\u2013": "-",
1331
+ "\u2018": "'", "\u2019": "'",
1332
+ "\u201c": '"', "\u201d": '"',
1333
+ "\u2026": "...",
1334
+ "\u2022": "*",
1335
+ }
1336
+ for src, dst in replacements.items():
1337
+ text = text.replace(src, dst)
1338
+ text = unicodedata.normalize("NFKD", text)
1339
+ return text.encode("latin-1", errors="replace").decode("latin-1")
1340
+
1341
+ def _md_to_plain(text: str) -> str:
1342
+ """Strip markdown syntax to plain text for PDF rendering."""
1343
+ if not text:
1344
+ return ""
1345
+ text = re.sub(r"^#{1,6}\s+", "", text, flags=re.MULTILINE)
1346
+ text = re.sub(r"\*{1,2}(.+?)\*{1,2}", r"\1", text)
1347
+ text = re.sub(r"`{1,3}[^`]*`{1,3}", "", text)
1348
+ text = re.sub(r"^\|.*\|$", "", text, flags=re.MULTILINE)
1349
+ text = re.sub(r"^[-|]+$", "", text, flags=re.MULTILINE)
1350
+ text = re.sub(r"\n{3,}", "\n\n", text)
1351
+ return _to_latin1(text.strip())
1352
+
1353
+ def _numpy_to_jpeg_bytes(arr) -> bytes | None:
1354
+ """Convert numpy array to JPEG bytes for embedding in PDF."""
1355
+ if arr is None:
1356
+ return None
1357
+ try:
1358
+ img = _PILImage.fromarray(arr.astype("uint8"))
1359
+ buf = _io.BytesIO()
1360
+ img.save(buf, format="JPEG", quality=85)
1361
+ return buf.getvalue()
1362
+ except Exception:
1363
+ return None
1364
+
1365
+ class ForensicPDF(FPDF):
1366
+ def header(self):
1367
+ self.set_font("Helvetica", "B", 10)
1368
+ self.set_text_color(80, 80, 80)
1369
+ self.cell(0, 8, "GraphoLab - Referto Forense Integrato", align="C")
1370
+ self.ln(2)
1371
+ self.set_draw_color(180, 180, 180)
1372
+ self.line(10, self.get_y(), 200, self.get_y())
1373
+ self.ln(4)
1374
+
1375
+ def footer(self):
1376
+ self.set_y(-15)
1377
+ self.set_font("Helvetica", "I", 8)
1378
+ self.set_text_color(130, 130, 130)
1379
+ self.cell(0, 10, f"Pagina {self.page_no()} - Generato da GraphoLab", align="C")
1380
+
1381
+ pdf = ForensicPDF()
1382
+ pdf.set_auto_page_break(auto=True, margin=18)
1383
+ pdf.add_page()
1384
+
1385
+ # ── Titolo ────────────────────────────────────────────────────────────────
1386
+ pdf.set_font("Helvetica", "B", 18)
1387
+ pdf.set_text_color(30, 30, 30)
1388
+ pdf.cell(0, 12, "Referto Forense Integrato", align="C")
1389
+ pdf.ln(4)
1390
+ pdf.set_font("Helvetica", "", 10)
1391
+ pdf.set_text_color(100, 100, 100)
1392
+ now = datetime.datetime.now().strftime("%d/%m/%Y %H:%M")
1393
+ pdf.cell(0, 8, f"Data generazione: {now}", align="C")
1394
+ pdf.ln(10)
1395
+
1396
+ def _section_title(title: str):
1397
+ pdf.set_font("Helvetica", "B", 12)
1398
+ pdf.set_text_color(255, 255, 255)
1399
+ pdf.set_fill_color(50, 80, 120)
1400
+ pdf.cell(0, 8, _to_latin1(f" {title}"), fill=True)
1401
+ pdf.ln(4)
1402
+ pdf.set_text_color(30, 30, 30)
1403
+
1404
+ def _body_text(text: str):
1405
+ if not text:
1406
+ return
1407
+ pdf.set_font("Helvetica", "", 10)
1408
+ pdf.set_text_color(40, 40, 40)
1409
+ pdf.multi_cell(0, 5, _md_to_plain(text))
1410
+ pdf.ln(3)
1411
+
1412
+ def _embed_image(arr, max_w: int = 170):
1413
+ data = _numpy_to_jpeg_bytes(arr)
1414
+ if data is None:
1415
+ return
1416
+ buf = _io.BytesIO(data)
1417
+ img = _PILImage.open(buf)
1418
+ w, h = img.size
1419
+ ratio = min(max_w / w, 100 / h)
1420
+ disp_w, disp_h = w * ratio, h * ratio
1421
+ buf.seek(0)
1422
+ x = (210 - disp_w) / 2
1423
+ pdf.image(buf, x=x, w=disp_w, h=disp_h)
1424
+ pdf.ln(4)
1425
+
1426
+ # ── Step 1 ────────────────────────────────────────────────────────────────
1427
+ _section_title("Step 1 — Rilevamento Firma (YOLOv8)")
1428
+ _body_text(s1_txt)
1429
+ _embed_image(s1_img)
1430
+
1431
+ # ── Step 2 ────────────────────────────────────────────────────────────────
1432
+ _section_title("Step 2 — Trascrizione HTR (EasyOCR)")
1433
+ _body_text(s2_txt)
1434
+
1435
+ # ── Step 3 ────────────────────────────────────────────────────────────────
1436
+ _section_title("Step 3 — Riconoscimento Entita' (NER)")
1437
+ _body_text(s3_md)
1438
+
1439
+ # ── Step 4 ────────────────────────────────────────────────────────────────
1440
+ _section_title("Step 4 — Identificazione Scrittore")
1441
+ _body_text(s4_md)
1442
+ _embed_image(s4_img)
1443
+
1444
+ # ── Step 5 ────────────────────────────────────────────────────────────────
1445
+ _section_title("Step 5 — Analisi Grafologica")
1446
+ _body_text(s5_md)
1447
+ _embed_image(s5_img)
1448
+
1449
+ # ── Step 6 ────────────────────────────────────────────────────────────────
1450
+ _section_title("Step 6 — Verifica Firma (SigNet)")
1451
+ _body_text(s6_txt)
1452
+ _embed_image(s6_img)
1453
+
1454
+ # ── Step 7: LLM ───────────────────────────────────────────────────────────
1455
+ _section_title("Step 7 — Valutazione LLM (Ollama)")
1456
+ _body_text(llm_text)
1457
+
1458
+ # ── Disclaimer ────────────────────────────────────────────────────────────
1459
+ pdf.ln(6)
1460
+ pdf.set_draw_color(180, 180, 180)
1461
+ pdf.line(10, pdf.get_y(), 200, pdf.get_y())
1462
+ pdf.ln(4)
1463
+ pdf.set_font("Helvetica", "I", 8)
1464
+ pdf.set_text_color(120, 120, 120)
1465
+ pdf.multi_cell(
1466
+ 0, 4,
1467
+ "Referto generato automaticamente da GraphoLab. "
1468
+ "Tutti i risultati hanno carattere indicativo e devono essere valutati "
1469
+ "da un perito calligrafo qualificato.",
1470
+ )
1471
+
1472
+ # ── Salvataggio ───────────────────────────────────────────────────────────
1473
+ tmp = tempfile.NamedTemporaryFile(
1474
+ suffix=".pdf", prefix="grapholab_referto_", delete=False
1475
+ )
1476
+ pdf.output(tmp.name)
1477
+ return tmp.name
1478
+
1479
+
1480
  with gr.Blocks() as pipeline_tab:
1481
  gr.Markdown(
1482
  "## Pipeline Forense Integrata\n\n"
 
1544
  with gr.Accordion("Step 7 — Valutazione LLM (Ollama)", open=True):
1545
  out_llm = gr.Markdown(label="Referto sintetico LLM")
1546
 
1547
+ with gr.Row(visible=False) as pdf_row:
1548
+ pdf_btn = gr.Button("📄 Scarica Report PDF", variant="secondary")
1549
+ pdf_out = gr.File(label="Report PDF", file_types=[".pdf"])
1550
+
1551
  pipe_btn.click(
1552
  fn=run_pipeline,
1553
  inputs=[pipe_doc, pipe_ref],
 
1562
  pipe_results,
1563
  out_llm,
1564
  ],
1565
+ ).then(
1566
+ fn=lambda: gr.update(visible=True),
1567
+ inputs=None,
1568
+ outputs=pdf_row,
1569
+ )
1570
+
1571
+ pdf_btn.click(
1572
+ fn=_generate_pipeline_pdf,
1573
+ inputs=[
1574
+ out_s1_img, out_s1_txt,
1575
+ out_s2_txt,
1576
+ out_s3_md,
1577
+ out_s4_md, out_s4_img,
1578
+ out_s5_md, out_s5_img,
1579
+ out_s6_txt, out_s6_img,
1580
+ out_llm,
1581
+ ],
1582
+ outputs=pdf_out,
1583
  )
1584
 
1585
 
requirements.txt CHANGED
@@ -15,6 +15,7 @@ ultralytics>=8.0.0
15
 
16
  # audioop backport for Python 3.13 (pydub dependency of gradio 4.x)
17
  audioop-lts; python_version >= "3.13"
 
18
 
19
  # Data / ML utilities
20
  scikit-learn>=1.3.0
 
15
 
16
  # audioop backport for Python 3.13 (pydub dependency of gradio 4.x)
17
  audioop-lts; python_version >= "3.13"
18
+ fpdf2>=2.7 # PDF report generation
19
 
20
  # Data / ML utilities
21
  scikit-learn>=1.3.0