diff --git a/CLAUDE.md b/CLAUDE.md
index e70e5eb..5098662 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -69,37 +69,35 @@ conversione/
├── clear.sh # Rimuove output di uno stem
└── _pipeline/
├── __init__.py # Re-export pubblico
- ├── deps.py # _check_deps() — verifica opendataloader-pdf e Java
- ├── checker.py # check_pdf() — validazione PDF
- ├── converter.py # convert_pdf() — wrapper opendataloader
+ ├── extract.py # _check_deps() + validate_pdf() + convert_pdf()
+ ├── runner.py # run() — orchestrazione 4 fasi
├── structure.py # analyze() + rilevamento lingua e struttura
├── report.py # build_report() → report.json
- ├── runner.py # run() — orchestrazione 4 fasi
├── validator.py # validate() + _score() + _grade()
- └── transforms/ # Package pulizia strutturale
- ├── __init__.py # Espone apply_transforms()
- ├── _apply.py # Orchestratore: lista _transforms in ordine semantico
- ├── _constants.py# Regex compilate e mappe statiche condivise
- ├── _encoding.py # Gruppo 1: PUA font Symbol, accenti LaTeX, simboli SI
- ├── _artifacts.py# Gruppo 2: immagini, BR, footnote, URL, righe ricorrenti, watermark
- ├── _headers.py # Gruppo 3: normalizzazione livelli, concat, bold, ALLCAPS
- ├── _structure.py# Gruppo 4: TOC, ALLCAPS→##, sezioni numerate, math, articoli
- ├── _text.py # Gruppo 5: merge paragrafi, whitespace, poesia, versi
- ├── _finish.py # Gruppo 6: header vuoti/garbage, formula-header, frontmatter
- └── _helpers.py # Funzioni pure condivise (es. _sentence_case)
+ ├── _apply.py # apply_transforms() — orchestratore in ordine semantico
+ ├── _constants.py # Regex compilate e mappe statiche condivise
+ ├── _encoding.py # Gruppo 1: PUA font Symbol, accenti LaTeX, simboli SI
+ ├── _artifacts.py # Gruppo 2: immagini, BR, footnote, URL, righe ricorrenti, watermark
+ ├── _headers.py # Gruppo 3: normalizzazione livelli, concat, bold, ALLCAPS
+ ├── _structure.py # Gruppo 4: TOC, ALLCAPS→##, sezioni numerate, math, articoli
+ ├── _text.py # Gruppo 5: merge paragrafi, whitespace, poesia, versi
+ ├── _finish.py # Gruppo 6: header vuoti/garbage, formula-header, frontmatter
+ └── _helpers.py # Funzioni pure condivise (_sentence_case, _is_allcaps_line, ecc.)
```
### `__main__.py` — entry point unificato
CLI con due modalità: conversione (default, `--stem`, `--force`) e validazione (subcommand `validate`, con stems opzionali e `--detail`). Aggiunge `conversione/` a `sys.path` e delega a `_pipeline`. Uso: `python conversione/ [--stem X] [--force]` oppure `python conversione/ validate [X] [--detail]`.
-### `_pipeline/transforms/` — cuore della pipeline
+### `_pipeline/extract.py` — fronte PDF
-Package con ~35 trasformazioni atomiche (`_t_*`). L'orchestratore `_apply.py::apply_transforms(text) -> (text, stats)` le chiama in ordine semantico fisso — **non modificare l'ordine senza capire le dipendenze tra gruppi**.
+Raggruppa in un unico modulo le tre responsabilità legate al PDF: `_check_deps()` verifica che `opendataloader-pdf` e Java 11+ siano disponibili; `validate_pdf(pdf_path) -> (bool, str)` controlla esistenza, dimensione e presenza di testo digitale; `convert_pdf(pdf_path, out_dir) -> Path` invoca opendataloader-pdf con i parametri RAG-ottimali e restituisce il percorso del `.md` prodotto. Auto-rileva i PDF taggati (Word/InDesign) per attivare `use_struct_tree`.
-Ogni `_t_*` vive nel modulo corrispondente al suo gruppo. Le costanti regex (compilate una volta) stanno tutte in `_constants.py` e vengono importate dove servono.
+### `_pipeline/_apply.py` — cuore della pipeline
-Ordine logico dei gruppi in `_apply.py`:
+Contiene `apply_transforms(text) -> (text, stats)` che chiama ~35 trasformazioni atomiche (`_t_*`) in ordine semantico fisso — **non modificare l'ordine senza capire le dipendenze tra gruppi**. Ogni `_t_*` vive nel modulo del suo gruppo; le costanti regex compilate stanno in `_constants.py`.
+
+Ordine logico dei gruppi:
1. **Encoding** (`_encoding.py`) — PUA font Symbol, accenti backtick LaTeX, moltiplicazione, micro
2. **Pulizia artefatti** (`_artifacts.py`) — immagini, `
`, footnote superscript, URL, box symbol, righe ricorrenti, watermark
3. **Struttura header** (`_headers.py`) — fix header+body concatenati, Capitolo inline, normalizzazione livelli numerati, `####`→`###`, bold, ALL-CAPS
@@ -157,9 +155,10 @@ Assegna un voto 0–100 (A/B/C/D/F) leggendo `report.json`. Penalità principali
Quando si aggiunge una trasformazione in `apply_transforms()`:
- Ogni `_t_*` deve restituire `(testo, n_modifiche)` — il contatore alimenta `report.json`.
- Implementare la funzione nel modulo del gruppo corretto (`_encoding.py`, `_artifacts.py`, ecc.), importarla in `_apply.py` e inserire la coppia `("stat_key", _t_nuova)` nella lista `_transforms` nel punto logicamente corretto.
-- Compilare i pattern regex in `_constants.py` come costanti di modulo, non dentro la funzione.
+- Compilare i pattern regex in `_constants.py` come costanti di modulo, mai dentro la funzione.
- Testare con `.venv/bin/python conversione/ --stem --force` e confrontare `report.json`.
-- Un nuovo tipo di artefatto: prima aggiungerlo come residuo in `report.py` (`_scan`), poi implementare la `_t_*` che lo rimuove.
+- Un nuovo tipo di artefatto: prima aggiungerlo come residuo in `report.py` (funzione `_scan`), poi implementare la `_t_*` che lo rimuove.
+- I residui in `report.py` usano `_MATH_SYMBOLS_RE`, `_EXERCISE_TRIGGER_RE` e `_MATH_HDR_RE` da `transforms._constants` — non ridefinirli localmente.
---
diff --git a/conversione/_pipeline/__init__.py b/conversione/_pipeline/__init__.py
index 5ff882c..db88d29 100644
--- a/conversione/_pipeline/__init__.py
+++ b/conversione/_pipeline/__init__.py
@@ -1,15 +1,13 @@
-from .deps import _check_deps
-from .checker import check_pdf
-from .converter import convert_pdf
-from .transforms import apply_transforms
-from .structure import analyze
+from .extract import _check_deps, validate_pdf, convert_pdf
+from ._apply import apply_transforms
+from .structure import analyze
from .report import build_report
from .runner import run
from .validator import validate
__all__ = [
"_check_deps",
- "check_pdf",
+ "validate_pdf",
"convert_pdf",
"apply_transforms",
"analyze",
diff --git a/conversione/_pipeline/transforms/_apply.py b/conversione/_pipeline/_apply.py
similarity index 50%
rename from conversione/_pipeline/transforms/_apply.py
rename to conversione/_pipeline/_apply.py
index efa1565..9bf8f21 100644
--- a/conversione/_pipeline/transforms/_apply.py
+++ b/conversione/_pipeline/_apply.py
@@ -31,66 +31,72 @@ from ._finish import (
)
-def apply_transforms(text: str) -> tuple[str, dict]:
+def apply_transforms(text: str, on_step=None) -> tuple[str, dict]:
"""
Applica le trasformazioni strutturali al Markdown grezzo.
Restituisce (testo_modificato, statistiche).
L'ordine è semantico: encoding → artefatti → struttura header →
costruzione struttura → testo → rifinitura.
+
+ on_step(i, total, label) — callback opzionale chiamato dopo ogni step.
"""
_has_ex = bool(re.search(r"\b(Esercizi|Exercises|Problems|Homework)\b", text, re.IGNORECASE))
- _transforms: list[tuple[str | None, object]] = [
+ # (stat_key, fn, label)
+ _transforms: list[tuple[str | None, object, str]] = [
# 1. Encoding
- ("n_simboli_pua_corretti", _t_fix_symbol_font),
- ("n_accenti_corretti", _t_fix_accents),
- ("n_moltiplicazioni_corrette", _t_fix_multiplication),
- ("n_micro_corretti", _t_fix_micro),
+ ("n_simboli_pua_corretti", _t_fix_symbol_font, "encoding PUA Symbol"),
+ ("n_accenti_corretti", _t_fix_accents, "accenti backtick LaTeX"),
+ ("n_moltiplicazioni_corrette", _t_fix_multiplication, "simbolo moltiplicazione"),
+ ("n_micro_corretti", _t_fix_micro, "simbolo micro SI"),
# 2. Pulizia artefatti
- ("n_immagini_rimosse", _t_remove_images),
- ("n_br_rimossi", _t_fix_br),
- ("n_tabsep_rimossi", _t_fix_tabsep),
- ("n_note_rimosse", _t_remove_footnotes),
- ("n_simboli_math_rimossi", _t_fix_math_symbols),
- ("n_formule_rimossi", _t_remove_formula_labels),
- ("n_dotleader_rimossi", _t_remove_dotleaders),
- ("n_righe_ricorrenti_rimosse", _t_remove_recurring_lines),
+ ("n_immagini_rimosse", _t_remove_images, "rimozione immagini"),
+ ("n_br_rimossi", _t_fix_br, "fix
inline"),
+ ("n_tabsep_rimossi", _t_fix_tabsep, "fix separatori tabella"),
+ ("n_note_rimosse", _t_remove_footnotes, "rimozione footnote"),
+ ("n_simboli_math_rimossi", _t_fix_math_symbols, "rimozione box math"),
+ ("n_formule_rimossi", _t_remove_formula_labels, "rimozione label formula"),
+ ("n_dotleader_rimossi", _t_remove_dotleaders, "rimozione dot-leader TOC"),
+ ("n_righe_ricorrenti_rimosse", _t_remove_recurring_lines, "rimozione righe ricorrenti"),
# 3. Struttura header
- ("n_header_concat_fixati", _t_fix_header_concat),
- (None, _t_extract_capitolo),
- ("n_header_numerati_normalizzati", _t_normalize_numbered_headings),
- (None, _t_normalize_header_levels),
- (None, _t_remove_header_bold),
- (None, _t_normalize_allcaps_headers),
+ ("n_header_concat_fixati", _t_fix_header_concat, "fix header+corpo concatenati"),
+ (None, _t_extract_capitolo, "estrazione Capitolo inline"),
+ ("n_header_numerati_normalizzati", _t_normalize_numbered_headings, "normalizzazione livelli numerati"),
+ (None, _t_normalize_header_levels, "normalizzazione livelli ####→###"),
+ (None, _t_remove_header_bold, "rimozione bold negli header"),
+ (None, _t_normalize_allcaps_headers, "normalizzazione ALL-CAPS header"),
# 4. Costruzione struttura
- ("toc_rimosso", _t_remove_toc),
- ("n_toc_orfani_rimossi", _t_remove_orphan_toc),
- ("n_header_allcaps", _t_allcaps_to_headers),
- ("n_sezioni_numerate", partial(_t_numbered_sections, has_exercises=_has_ex)),
- ("n_ambienti_matematici", _t_extract_math),
- ("n_articoli_estratti", _t_extract_articles),
+ ("toc_rimosso", _t_remove_toc, "rimozione TOC"),
+ ("n_toc_orfani_rimossi", _t_remove_orphan_toc, "rimozione voci TOC orfane"),
+ ("n_header_allcaps", _t_allcaps_to_headers, "ALL-CAPS → ##"),
+ ("n_sezioni_numerate", partial(_t_numbered_sections, has_exercises=_has_ex), "sezioni numerate → ###"),
+ ("n_ambienti_matematici", _t_extract_math, "estrazione ambienti matematici"),
+ ("n_articoli_estratti", _t_extract_articles, "estrazione articoli → ###"),
# 5. Testo
- ("n_paragrafi_uniti", _t_merge_paragraphs),
- (None, _t_normalize_whitespace),
- (None, _t_collapse_blank_lines),
- ("n_versi_ripristinati", _t_restore_poetry_lines),
- ("n_header_verso_demotati", _t_demote_verse_headers),
- (None, _t_remove_urls),
+ ("n_paragrafi_uniti", _t_merge_paragraphs, "merge paragrafi spezzati"),
+ (None, _t_normalize_whitespace, "normalizzazione whitespace"),
+ (None, _t_collapse_blank_lines, "riduzione righe vuote"),
+ ("n_versi_ripristinati", _t_restore_poetry_lines, "ripristino versi poesia"),
+ ("n_header_verso_demotati", _t_demote_verse_headers, "demozione header-verso"),
+ ("n_url_rimossi", _t_remove_urls, "rimozione URL"),
# 6. Rifinitura
- (None, _t_remove_empty_headers),
- ("n_titoli_uniti", _t_merge_title_headers),
- (None, lambda t: (re.sub(r"(?m)^(#{1,6}.+?)\s*\|\s*\d{1,3}\s*$", r"\1", t), 0)),
- ("n_garbage_headers_rimossi", _t_remove_garbage_headers),
- ("n_formula_headers_demotati", _t_math_header_demotion),
- ("n_frontmatter_rimossi", _t_remove_frontmatter),
- ("n_watermark_rimossi", _t_remove_watermarks),
+ (None, _t_remove_empty_headers, "rimozione header vuoti"),
+ ("n_titoli_uniti", _t_merge_title_headers, "merge titoli isolati"),
+ (None, lambda t: (re.sub(r"(?m)^(#{1,6}.+?)\s*\|\s*\d{1,3}\s*$", r"\1", t), 0), "fix header|pagina"),
+ ("n_garbage_headers_rimossi", _t_remove_garbage_headers, "rimozione garbage header"),
+ ("n_formula_headers_demotati", _t_math_header_demotion, "demozione formula-header"),
+ ("n_frontmatter_rimossi", _t_remove_frontmatter, "rimozione frontmatter"),
+ ("n_watermark_rimossi", _t_remove_watermarks, "rimozione watermark"),
]
+ total = len(_transforms)
stats: dict = {}
- for stat_key, fn in _transforms:
+ for i, (stat_key, fn, label) in enumerate(_transforms, 1):
text, n = fn(text)
if stat_key:
stats[stat_key] = stats.get(stat_key, 0) + n
+ if on_step:
+ on_step(i, total, label)
stats["toc_rimosso"] = bool(stats.get("toc_rimosso", 0))
return text, stats
diff --git a/conversione/_pipeline/transforms/_artifacts.py b/conversione/_pipeline/_artifacts.py
similarity index 87%
rename from conversione/_pipeline/transforms/_artifacts.py
rename to conversione/_pipeline/_artifacts.py
index a3e2f67..a5333b1 100644
--- a/conversione/_pipeline/transforms/_artifacts.py
+++ b/conversione/_pipeline/_artifacts.py
@@ -3,7 +3,7 @@ import re
from collections import Counter
from ._constants import (
- _WATERMARK_RE, _TABSEP_RE, _SUPERSCRIPT_RE, _FOOTNOTE_BODY_RE,
+ _WATERMARK_RE, _TABSEP_RE, _SUPERSCRIPT_RE, _FOOTNOTE_BODY_RE, _DOTLEADER_RE,
)
@@ -47,15 +47,9 @@ def _t_remove_formula_labels(text: str) -> tuple[str, int]:
def _t_remove_dotleaders(text: str) -> tuple[str, int]:
- _DOTLEADER_RE = r"^[^\n]*(?:(?:\. ){3,}|\.{4,})[^\n]*$"
- n = len(re.findall(_DOTLEADER_RE, text, re.MULTILINE))
- text = re.sub(_DOTLEADER_RE, "", text, flags=re.MULTILINE)
- text = re.sub(
- r"(?m)^(i{1,3}|iv|vi{0,3}|ix|xi{0,2}|x)$",
- "",
- text,
- flags=re.IGNORECASE,
- )
+ n = len(_DOTLEADER_RE.findall(text))
+ text = _DOTLEADER_RE.sub("", text)
+ text = re.sub(r"(?m)^(i{1,3}|iv|vi{0,3}|ix|xi{0,2}|x)$", "", text, flags=re.IGNORECASE)
return text, n
@@ -103,4 +97,6 @@ def _t_remove_watermarks(text: str) -> tuple[str, int]:
def _t_remove_urls(text: str) -> tuple[str, int]:
- return re.sub(r"(?m)^(https?://|www\.)\S+\s*$", "", text), 0
+ n = len(re.findall(r"(?m)^(https?://|www\.)\S+\s*$", text))
+ text = re.sub(r"(?m)^(https?://|www\.)\S+\s*$", "", text)
+ return text, n
diff --git a/conversione/_pipeline/transforms/_constants.py b/conversione/_pipeline/_constants.py
similarity index 98%
rename from conversione/_pipeline/transforms/_constants.py
rename to conversione/_pipeline/_constants.py
index 18760e0..baa25a0 100644
--- a/conversione/_pipeline/transforms/_constants.py
+++ b/conversione/_pipeline/_constants.py
@@ -132,6 +132,7 @@ _WATERMARK_RE = re.compile(
re.IGNORECASE | re.MULTILINE,
)
_TABSEP_RE = re.compile(r"(?m)^\|\s*\|\s*$|^\|---\|?\s*$")
+_DOTLEADER_RE = re.compile(r"^[^\n]*(?:(?:\. ){3,}|\.{4,})[^\n]*$", re.MULTILINE)
_FM_RE = re.compile(
r"https?://|www\.|@[A-Za-z]|\bUniversit[àa]\b|\bDipartimento\b|"
r"\bCopyright\b|\bLicenza\b|\bEdizione\b|"
diff --git a/conversione/_pipeline/transforms/_encoding.py b/conversione/_pipeline/_encoding.py
similarity index 100%
rename from conversione/_pipeline/transforms/_encoding.py
rename to conversione/_pipeline/_encoding.py
diff --git a/conversione/_pipeline/transforms/_finish.py b/conversione/_pipeline/_finish.py
similarity index 100%
rename from conversione/_pipeline/transforms/_finish.py
rename to conversione/_pipeline/_finish.py
diff --git a/conversione/_pipeline/transforms/_headers.py b/conversione/_pipeline/_headers.py
similarity index 100%
rename from conversione/_pipeline/transforms/_headers.py
rename to conversione/_pipeline/_headers.py
diff --git a/conversione/_pipeline/transforms/_helpers.py b/conversione/_pipeline/_helpers.py
similarity index 100%
rename from conversione/_pipeline/transforms/_helpers.py
rename to conversione/_pipeline/_helpers.py
diff --git a/conversione/_pipeline/transforms/_structure.py b/conversione/_pipeline/_structure.py
similarity index 100%
rename from conversione/_pipeline/transforms/_structure.py
rename to conversione/_pipeline/_structure.py
diff --git a/conversione/_pipeline/transforms/_text.py b/conversione/_pipeline/_text.py
similarity index 100%
rename from conversione/_pipeline/transforms/_text.py
rename to conversione/_pipeline/_text.py
diff --git a/conversione/_pipeline/checker.py b/conversione/_pipeline/checker.py
deleted file mode 100644
index 87b667a..0000000
--- a/conversione/_pipeline/checker.py
+++ /dev/null
@@ -1,51 +0,0 @@
-from pathlib import Path
-
-
-def check_pdf(pdf_path: Path) -> tuple[bool, str]:
- """Validazione rapida: esistenza, leggibilità, testo estraibile."""
- if not pdf_path.exists():
- return False, f"File non trovato: {pdf_path}"
- if pdf_path.suffix.lower() != ".pdf":
- return False, f"Non è un PDF: {pdf_path.name}"
- size = pdf_path.stat().st_size
- if size == 0:
- return False, "File vuoto"
- if size < 1024:
- return False, f"File troppo piccolo ({size} byte) — probabilmente corrotto"
-
- try:
- import pdfplumber
- with pdfplumber.open(pdf_path) as pdf:
- n_pages = len(pdf.pages)
- if n_pages == 0:
- return False, "PDF senza pagine"
- sample = min(5, n_pages)
- pages_with_text = sum(
- 1 for i in range(sample)
- if len((pdf.pages[i].extract_text() or "").strip()) > 50
- )
- if pages_with_text == 0:
- extended = min(15, n_pages)
- if extended > sample:
- ext_with_text = sum(
- 1 for i in range(sample, extended)
- if len((pdf.pages[i].extract_text() or "").strip()) > 50
- )
- if ext_with_text > 0:
- return True, (
- f"{n_pages} pagine — prime {sample} vuote, "
- f"testo trovato in pagine successive "
- f"(possibile copertina immagine)"
- )
- return False, (
- f"Nessun testo nelle prime {extended} pagine "
- f"— probabilmente scansionato (OCR non supportato)"
- )
- return True, f"{n_pages} pagine, testo digitale confermato"
- except MemoryError:
- return False, "Memoria esaurita durante l'apertura del PDF"
- except Exception as e:
- msg = str(e).lower()
- if "password" in msg or "encrypted" in msg:
- return False, "PDF protetto da password"
- return False, f"Impossibile aprire: {e}"
diff --git a/conversione/_pipeline/converter.py b/conversione/_pipeline/converter.py
deleted file mode 100644
index 38f028d..0000000
--- a/conversione/_pipeline/converter.py
+++ /dev/null
@@ -1,62 +0,0 @@
-from pathlib import Path
-
-
-def _is_tagged_pdf(pdf_path: Path) -> bool:
- try:
- import fitz
- doc = fitz.open(str(pdf_path))
- tagged = "StructTreeRoot" in doc.pdf_catalog()
- doc.close()
- return tagged
- except Exception:
- return False
-
-
-def convert_pdf(pdf_path: Path, out_dir: Path) -> Path:
- """
- Converte il PDF in Markdown tramite opendataloader-pdf.
- Scrive il file nella out_dir e restituisce il percorso.
-
- Parametri scelti per output RAG-ottimale:
- - keep_line_breaks=False → testo fluente, no hard-wrap PDF
- - reading_order="xycut" → corregge ordine multi-colonna (XY-Cut++)
- - sanitize=False → preserva il testo originale
- - image_output="off" → nessuna immagine estratta né referenziata
- - table_method="cluster" → rileva tabelle senza bordi visibili
- - content_safety_off → evita filtraggio di footnote (tiny) e layer OCG
- - use_struct_tree → attivo solo se il PDF è taggato (Word/InDesign)
- """
- import opendataloader_pdf
-
- out_dir.mkdir(parents=True, exist_ok=True)
- tagged = _is_tagged_pdf(pdf_path)
-
- opendataloader_pdf.convert(
- input_path=str(pdf_path),
- output_dir=str(out_dir),
- format="markdown",
- keep_line_breaks=False,
- reading_order="xycut",
- sanitize=False,
- image_output="off",
- table_method="cluster",
- content_safety_off=["tiny", "hidden-ocg"],
- use_struct_tree=tagged,
- quiet=True,
- )
-
- md_file = out_dir / f"{pdf_path.stem}.md"
- if not md_file.exists():
- candidates = list(out_dir.glob("*.md"))
- if not candidates:
- raise RuntimeError(f"Nessun file .md prodotto in {out_dir}")
- md_file = candidates[0]
-
- content = md_file.read_text(encoding="utf-8", errors="replace").strip()
- if len(content) < 100:
- raise RuntimeError(
- f"opendataloader ha prodotto un file .md quasi vuoto ({len(content)} char) "
- f"— il PDF potrebbe essere corrotto o non supportato"
- )
-
- return md_file
diff --git a/conversione/_pipeline/deps.py b/conversione/_pipeline/deps.py
deleted file mode 100644
index 04dec8a..0000000
--- a/conversione/_pipeline/deps.py
+++ /dev/null
@@ -1,23 +0,0 @@
-import subprocess
-import sys
-
-
-def _check_deps() -> None:
- try:
- import opendataloader_pdf # noqa: F401
- except ImportError:
- print("Errore: opendataloader-pdf non installato.")
- print(" pip install opendataloader-pdf")
- sys.exit(1)
-
- try:
- result = subprocess.run(
- ["java", "-version"],
- capture_output=True, text=True,
- )
- if result.returncode != 0:
- raise FileNotFoundError
- except FileNotFoundError:
- print("Errore: Java 11+ non trovato sul PATH.")
- print(" Installa da https://adoptium.net/")
- sys.exit(1)
diff --git a/conversione/_pipeline/extract.py b/conversione/_pipeline/extract.py
new file mode 100644
index 0000000..4876d85
--- /dev/null
+++ b/conversione/_pipeline/extract.py
@@ -0,0 +1,138 @@
+"""Estrazione PDF: verifica dipendenze, validazione, conversione → raw Markdown."""
+import subprocess
+import sys
+from pathlib import Path
+
+
+# ─── Dipendenze ───────────────────────────────────────────────────────────────
+
+def _check_deps() -> None:
+ try:
+ import opendataloader_pdf # noqa: F401
+ except ImportError:
+ print("Errore: opendataloader-pdf non installato.")
+ print(" pip install opendataloader-pdf")
+ sys.exit(1)
+
+ try:
+ result = subprocess.run(["java", "-version"], capture_output=True, text=True)
+ if result.returncode != 0:
+ raise FileNotFoundError
+ except FileNotFoundError:
+ print("Errore: Java 11+ non trovato sul PATH.")
+ print(" Installa da https://adoptium.net/")
+ sys.exit(1)
+
+
+# ─── Validazione PDF ──────────────────────────────────────────────────────────
+
+def validate_pdf(pdf_path: Path) -> tuple[bool, str]:
+ """Verifica esistenza, leggibilità e presenza di testo digitale estraibile."""
+ if not pdf_path.exists():
+ return False, f"File non trovato: {pdf_path}"
+ if pdf_path.suffix.lower() != ".pdf":
+ return False, f"Non è un PDF: {pdf_path.name}"
+ size = pdf_path.stat().st_size
+ if size == 0:
+ return False, "File vuoto"
+ if size < 1024:
+ return False, f"File troppo piccolo ({size} byte) — probabilmente corrotto"
+
+ try:
+ import pdfplumber
+ with pdfplumber.open(pdf_path) as pdf:
+ n_pages = len(pdf.pages)
+ if n_pages == 0:
+ return False, "PDF senza pagine"
+ sample = min(5, n_pages)
+ pages_with_text = sum(
+ 1 for i in range(sample)
+ if len((pdf.pages[i].extract_text() or "").strip()) > 50
+ )
+ if pages_with_text == 0:
+ extended = min(15, n_pages)
+ if extended > sample:
+ ext_with_text = sum(
+ 1 for i in range(sample, extended)
+ if len((pdf.pages[i].extract_text() or "").strip()) > 50
+ )
+ if ext_with_text > 0:
+ return True, (
+ f"{n_pages} pagine — prime {sample} vuote, "
+ f"testo trovato in pagine successive "
+ f"(possibile copertina immagine)"
+ )
+ return False, (
+ f"Nessun testo nelle prime {extended} pagine "
+ f"— probabilmente scansionato (OCR non supportato)"
+ )
+ return True, f"{n_pages} pagine, testo digitale confermato"
+ except MemoryError:
+ return False, "Memoria esaurita durante l'apertura del PDF"
+ except Exception as e:
+ msg = str(e).lower()
+ if "password" in msg or "encrypted" in msg:
+ return False, "PDF protetto da password"
+ return False, f"Impossibile aprire: {e}"
+
+
+# ─── Conversione PDF → Markdown ───────────────────────────────────────────────
+
+def _is_tagged_pdf(pdf_path: Path) -> bool:
+ try:
+ import fitz
+ doc = fitz.open(str(pdf_path))
+ tagged = "StructTreeRoot" in doc.pdf_catalog()
+ doc.close()
+ return tagged
+ except Exception:
+ return False
+
+
+def convert_pdf(pdf_path: Path, out_dir: Path) -> Path:
+ """
+ Converte il PDF in Markdown tramite opendataloader-pdf (XY-Cut++).
+
+ Parametri per output RAG-ottimale:
+ keep_line_breaks=False → testo fluente, elimina hard-wrap del PDF
+ reading_order="xycut" → ricostruisce ordine di lettura multi-colonna
+ sanitize=False → preserva il testo originale senza filtri
+ image_output="off" → nessuna immagine estratta né referenziata
+ table_method="cluster" → rileva tabelle anche senza bordi visibili
+ content_safety_off → non scarta footnote (tiny) né layer OCG nascosti
+ use_struct_tree → attivo solo per PDF taggati (Word/InDesign)
+ """
+ import opendataloader_pdf
+
+ out_dir.mkdir(parents=True, exist_ok=True)
+ tagged = _is_tagged_pdf(pdf_path)
+
+ opendataloader_pdf.convert(
+ input_path=str(pdf_path),
+ output_dir=str(out_dir),
+ format="markdown",
+ keep_line_breaks=False,
+ reading_order="xycut",
+ sanitize=False,
+ image_output="off",
+ table_method="cluster",
+ content_safety_off=["tiny", "hidden-ocg"],
+ use_struct_tree=tagged,
+ quiet=True,
+ )
+
+ md_file = out_dir / f"{pdf_path.stem}.md"
+ if not md_file.exists():
+ candidates = list(out_dir.glob("*.md"))
+ if not candidates:
+ raise RuntimeError(f"Nessun file .md prodotto in {out_dir}")
+ md_file = candidates[0]
+
+ content = md_file.read_text(encoding="utf-8", errors="replace").strip()
+ if len(content) < 100:
+ raise RuntimeError(
+ f"opendataloader ha prodotto un file .md quasi vuoto ({len(content)} char) "
+ f"— il PDF potrebbe essere corrotto o non supportato"
+ )
+
+ return md_file
diff --git a/conversione/_pipeline/report.py b/conversione/_pipeline/report.py
index 093bc86..e1518b5 100644
--- a/conversione/_pipeline/report.py
+++ b/conversione/_pipeline/report.py
@@ -1,10 +1,10 @@
import json
import re
-from collections import Counter
from datetime import datetime
from pathlib import Path
from .structure import _parse_sections_with_body
+from ._constants import _MATH_SYMBOLS_RE, _EXERCISE_TRIGGER_RE, _MATH_HDR_RE
def build_report(
@@ -59,26 +59,17 @@ def build_report(
break
return hits
- _math_sym_scan = re.compile(
- r"[=+∈∀∃≤≥∞∑∫∂→↔⊂⊃∩∪αβγδεζηθικλμνξοπρστυφχψωΑΒΓΔΕΖΗΘΙΚΛΜΝΞΟΠΡΣΤΥΦΧΨΩ]"
- )
- _ex_trigger_scan = re.compile(
- r"\b(Si dimostri|Si calcoli|Si provi|Si trovi|Trovare|Find|Prove|Show that"
- r"|Compute|Calculate|Dimostrare|Verificare)\b",
- re.IGNORECASE,
- )
-
def _scan_formula_headers(max_n: int = 10) -> list[dict]:
hits = []
for i, line in enumerate(text_lines):
- m = re.match(r"^(#{2,3})\s+(.+)$", line)
+ m = _MATH_HDR_RE.match(line)
if not m:
continue
body = m.group(2)
if len(body) <= 100:
continue
- has_math = len(_math_sym_scan.findall(body)) >= 3
- has_ex = bool(_ex_trigger_scan.search(body))
+ has_math = len(_MATH_SYMBOLS_RE.findall(body)) >= 3
+ has_ex = bool(_EXERCISE_TRIGGER_RE.search(body))
if has_math or has_ex:
hits.append({"riga": i + 1, "testo": line.strip()[:120]})
if len(hits) >= max_n:
diff --git a/conversione/_pipeline/runner.py b/conversione/_pipeline/runner.py
index 125aeb9..07b9b2b 100644
--- a/conversione/_pipeline/runner.py
+++ b/conversione/_pipeline/runner.py
@@ -1,16 +1,51 @@
import json
+import sys
import tempfile
+import threading
+import time
from pathlib import Path
-from .checker import check_pdf
-from .converter import convert_pdf
-from .transforms import apply_transforms
+from .extract import validate_pdf, convert_pdf
+from ._apply import apply_transforms
from .structure import analyze
from .report import build_report
+from .validator import _score, _grade
_LIVELLO_DESC = {3: "ricca (h3)", 2: "parziale (h2)", 1: "paragrafi", 0: "testo piatto"}
+_SPIN_FRAMES = "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏"
+
+
+class _Spinner:
+ """Spinner animato in un thread separato — mostra frame + tempo trascorso."""
+
+ def __init__(self, prefix: str):
+ self._prefix = prefix
+ self._stop = threading.Event()
+ self._thread = threading.Thread(target=self._run, daemon=True)
+ self._t0 = 0.0
+
+ def __enter__(self):
+ self._t0 = time.perf_counter()
+ self._thread.start()
+ return self
+
+ def __exit__(self, *_):
+ self._stop.set()
+ self._thread.join()
+ sys.stdout.write("\r" + " " * 72 + "\r")
+ sys.stdout.flush()
+
+ def _run(self):
+ i = 0
+ while not self._stop.wait(0.1):
+ elapsed = time.perf_counter() - self._t0
+ frame = _SPIN_FRAMES[i % len(_SPIN_FRAMES)]
+ sys.stdout.write(f"\r {frame} {self._prefix} {elapsed:.0f}s")
+ sys.stdout.flush()
+ i += 1
+
def run(stem: str, project_root: Path, force: bool) -> bool:
pdf_path = project_root / "sources" / f"{stem}.pdf"
@@ -29,7 +64,9 @@ def run(stem: str, project_root: Path, force: bool) -> bool:
# [1] Validazione
print(" [1/4] Validazione PDF...")
- ok, msg = check_pdf(pdf_path)
+ pdf_mb = pdf_path.stat().st_size / (1024 * 1024) if pdf_path.exists() else 0
+ print(f" File: {pdf_path.name} ({pdf_mb:.1f} MB)")
+ ok, msg = validate_pdf(pdf_path)
if not ok:
print(f" ✗ {msg}")
return False
@@ -37,47 +74,69 @@ def run(stem: str, project_root: Path, force: bool) -> bool:
# [2] Conversione
print(" [2/4] Conversione PDF → Markdown (opendataloader-pdf)...")
- with tempfile.TemporaryDirectory() as tmp:
- try:
- md_file = convert_pdf(pdf_path, Path(tmp))
- except MemoryError:
- print(" ✗ Memoria esaurita durante la conversione")
- return False
- except Exception as e:
- print(f" ✗ Conversione fallita: {e}")
- return False
- try:
- raw_text = md_file.read_text(encoding="utf-8")
- except UnicodeDecodeError as e:
- print(f" ✗ Errore encoding nel file prodotto: {e}")
- return False
+ with _Spinner("opendataloader-pdf in esecuzione...") as spinner:
+ t0 = time.perf_counter()
+ with tempfile.TemporaryDirectory() as tmp:
+ try:
+ md_file = convert_pdf(pdf_path, Path(tmp))
+ except MemoryError:
+ print(" ✗ Memoria esaurita durante la conversione")
+ return False
+ except Exception as e:
+ print(f" ✗ Conversione fallita: {e}")
+ return False
+ try:
+ raw_text = md_file.read_text(encoding="utf-8")
+ except UnicodeDecodeError as e:
+ print(f" ✗ Errore encoding nel file prodotto: {e}")
+ return False
+ elapsed = time.perf_counter() - t0
size_kb = len(raw_text.encode()) // 1024
n_lines = raw_text.count("\n")
- print(f" ✅ Markdown grezzo: {size_kb} KB, {n_lines} righe")
+ print(f" ✅ Markdown grezzo: {size_kb} KB, {n_lines} righe ({elapsed:.1f}s)")
# [3] Pulizia strutturale
print(" [3/4] Pulizia strutturale...")
- clean_text, t = apply_transforms(raw_text)
+
+ def _on_step(i: int, total: int, label: str) -> None:
+ sys.stdout.write(f"\r [{i}/{total}] {label:<45}")
+ sys.stdout.flush()
+
+ clean_text, t = apply_transforms(raw_text, on_step=_on_step)
+ sys.stdout.write("\r" + " " * 72 + "\r")
+ sys.stdout.flush()
reduction = 100 * (1 - len(clean_text) / len(raw_text)) if raw_text else 0
- print(f" ✅ Simboli PUA corretti: {t['n_simboli_pua_corretti']}")
- print(f" Immagini rimosse: {t['n_immagini_rimosse']}")
- print(f" Note rimosse: {t['n_note_rimosse']}")
+ print(f" ✅ Encoding")
+ print(f" Simboli PUA corretti: {t['n_simboli_pua_corretti']}")
print(f" Accenti corretti: {t['n_accenti_corretti']}")
+ print(f" Artefatti")
+ print(f" Immagini rimosse: {t['n_immagini_rimosse']}")
+ print(f"
rimossi: {t['n_br_rimossi']}")
+ print(f" Note rimosse: {t['n_note_rimosse']}")
print(f" Dot-leader rimossi: {t['n_dotleader_rimossi']}")
+ print(f" Righe ricorrenti rim.: {t['n_righe_ricorrenti_rimosse']}")
+ print(f" URL rimossi: {t['n_url_rimossi']}")
+ print(f" Watermark rimossi: {t['n_watermark_rimossi']}")
+ print(f" Header")
print(f" Header concat fixati: {t['n_header_concat_fixati']}")
print(f" Header num. normaliz.: {t['n_header_numerati_normalizzati']}")
- print(f" Articoli → ###: {t['n_articoli_estratti']}")
- print(f" Ambienti matematici: {t['n_ambienti_matematici']}")
- print(f" Titoli header uniti: {t['n_titoli_uniti']}")
+ print(f" Struttura")
print(f" TOC rimosso: {'sì' if t['toc_rimosso'] else 'no'}")
print(f" TOC orfani rimossi: {t['n_toc_orfani_rimossi']}")
- print(f" Versi poesia riprist.: {t['n_versi_ripristinati']}")
- print(f" Header verso demotati: {t['n_header_verso_demotati']}")
print(f" ALL-CAPS → ##: {t['n_header_allcaps']}")
print(f" Sezioni → ###: {t['n_sezioni_numerate']}")
+ print(f" Ambienti matematici: {t['n_ambienti_matematici']}")
+ print(f" Articoli → ###: {t['n_articoli_estratti']}")
+ print(f" Testo")
print(f" Paragrafi uniti: {t['n_paragrafi_uniti']}")
+ print(f" Versi poesia riprist.: {t['n_versi_ripristinati']}")
+ print(f" Header verso demotati: {t['n_header_verso_demotati']}")
+ print(f" Rifinitura")
+ print(f" Garbage header rim.: {t['n_garbage_headers_rimossi']}")
+ print(f" Titoli header uniti: {t['n_titoli_uniti']}")
print(f" Formula-hdr demotati: {t['n_formula_headers_demotati']}")
+ print(f" Frontmatter rimossi: {t['n_frontmatter_rimossi']}")
print(f" Riduzione testo: {reduction:.0f}%")
# [4] Profilo strutturale
@@ -104,8 +163,11 @@ def run(stem: str, project_root: Path, force: bool) -> bool:
for w in profile["avvertenze"]:
print(f" ⚠️ {w}")
- build_report(stem, out_dir, clean_text, t, profile, reduction)
+ report_path = build_report(stem, out_dir, clean_text, t, profile, reduction)
+ report_data = json.loads(report_path.read_text(encoding="utf-8"))
+ score, _ = _score(report_data)
print(f"\n Output → conversione/{stem}/")
print(f" raw.md (immutabile) clean.md report.json")
+ print(f" Punteggio qualità: {score}/100 {_grade(score)}")
return True
diff --git a/conversione/_pipeline/transforms/__init__.py b/conversione/_pipeline/transforms/__init__.py
deleted file mode 100644
index 9b02e60..0000000
--- a/conversione/_pipeline/transforms/__init__.py
+++ /dev/null
@@ -1,4 +0,0 @@
-"""Package transforms: pipeline di pulizia strutturale per Markdown RAG."""
-from ._apply import apply_transforms
-
-__all__ = ["apply_transforms"]
diff --git a/docs/superpowers/plans/2026-04-30-pipeline-ottimizzazione.md b/docs/superpowers/plans/2026-04-30-pipeline-ottimizzazione.md
deleted file mode 100644
index 91694f9..0000000
--- a/docs/superpowers/plans/2026-04-30-pipeline-ottimizzazione.md
+++ /dev/null
@@ -1,560 +0,0 @@
-# Pipeline ottimizzazione PDF→Markdown — Implementation Plan
-
-> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
-
-**Goal:** Eliminare la necessità di revisione manuale del `clean.md` ottimizzando i parametri di opendataloader-pdf e aggiungendo trasformazioni mirate per tutti i tipi di PDF.
-
-**Architecture:** Quattro file modificati: `converter.py` (parametri adattivi + rilevamento PDF taggato), `transforms.py` (PUA bracket TeX + demozione header-formula), `report.py` (nuova metrica residua), `validator.py` (nuova penalità). Nessun cambio all'API pubblica di `_pipeline`.
-
-**Tech Stack:** Python 3.12, opendataloader-pdf (Java), PyMuPDF (fitz), regex
-
----
-
-## File modificati
-
-| File | Tipo | Responsabilità |
-|------|------|----------------|
-| `conversione/_pipeline/converter.py` | Modify | `_is_tagged_pdf()` + nuovi parametri convert |
-| `conversione/_pipeline/transforms.py` | Modify | PUA bracket TeX + `_t_math_header_demotion` |
-| `conversione/_pipeline/report.py` | Modify | `formula_headers_residui` nella sezione residui |
-| `conversione/_pipeline/validator.py` | Modify | Penalità formula headers |
-
----
-
-## Task 1: Converter adattivo — `_is_tagged_pdf()` + nuovi parametri
-
-**Files:**
-- Modify: `conversione/_pipeline/converter.py`
-
-- [ ] **Step 1: Leggi il file attuale**
-
-```bash
-cat conversione/_pipeline/converter.py
-```
-
-- [ ] **Step 2: Sostituisci interamente il contenuto**
-
-Il nuovo `converter.py` aggiunge `_is_tagged_pdf()` (usa fitz per controllare `StructTreeRoot` nel catalog del PDF) e passa i nuovi parametri a `opendataloader_pdf.convert()`:
-- `table_method="cluster"` — sempre attivo, migliora tabelle senza bordi
-- `content_safety_off=["tiny", "hidden-ocg"]` — evita filtraggio di footnote e layer OCG
-- `use_struct_tree=tagged` — attivo solo se PDF è taggato
-
-```python
-from pathlib import Path
-
-
-def _is_tagged_pdf(pdf_path: Path) -> bool:
- try:
- import fitz
- doc = fitz.open(str(pdf_path))
- tagged = "StructTreeRoot" in doc.pdf_catalog()
- doc.close()
- return tagged
- except Exception:
- return False
-
-
-def convert_pdf(pdf_path: Path, out_dir: Path) -> Path:
- """
- Converte il PDF in Markdown tramite opendataloader-pdf.
- Scrive il file nella out_dir e restituisce il percorso.
-
- Parametri scelti per output RAG-ottimale:
- - keep_line_breaks=False → testo fluente, no hard-wrap PDF
- - reading_order="xycut" → corregge ordine multi-colonna (XY-Cut++)
- - sanitize=False → preserva il testo originale
- - image_output="off" → nessuna immagine estratta né referenziata
- - table_method="cluster" → rileva tabelle senza bordi visibili
- - content_safety_off → evita filtraggio di footnote e layer OCG
- - use_struct_tree → attivo se PDF è taggato (Word/InDesign)
- """
- import opendataloader_pdf
-
- out_dir.mkdir(parents=True, exist_ok=True)
- tagged = _is_tagged_pdf(pdf_path)
-
- opendataloader_pdf.convert(
- input_path=str(pdf_path),
- output_dir=str(out_dir),
- format="markdown",
- keep_line_breaks=False,
- reading_order="xycut",
- sanitize=False,
- image_output="off",
- table_method="cluster",
- content_safety_off=["tiny", "hidden-ocg"],
- use_struct_tree=tagged,
- quiet=True,
- )
-
- md_file = out_dir / f"{pdf_path.stem}.md"
- if not md_file.exists():
- candidates = list(out_dir.glob("*.md"))
- if not candidates:
- raise RuntimeError(f"Nessun file .md prodotto in {out_dir}")
- md_file = candidates[0]
-
- content = md_file.read_text(encoding="utf-8", errors="replace").strip()
- if len(content) < 100:
- raise RuntimeError(
- f"opendataloader ha prodotto un file .md quasi vuoto ({len(content)} char) "
- f"— il PDF potrebbe essere corrotto o non supportato"
- )
-
- return md_file
-```
-
-- [ ] **Step 3: Verifica sintattica**
-
-```bash
-.venv/bin/python -c "from conversione._pipeline.converter import convert_pdf, _is_tagged_pdf; print('OK')"
-```
-
-Atteso: `OK`
-
-- [ ] **Step 4: Commit**
-
-```bash
-git add conversione/_pipeline/converter.py
-git commit -m "feat(converter): parametri adattivi — use_struct_tree, cluster tables, content-safety"
-```
-
----
-
-## Task 2: Aggiunta PUA bracket TeX (U+F8EB–U+F8FE)
-
-**Files:**
-- Modify: `conversione/_pipeline/transforms.py` (sezione `_SYMBOL_PUA_MAP`, righe ~28–127)
-
-Questi codepoint sono pezzi di parentesi/bracket grandi del font Computer Modern (TeX), non ricostruibili come singolo simbolo → mappati a `""`.
-
-- [ ] **Step 1: Aggiungi le entries mancanti alla fine di `_SYMBOL_PUA_MAP`**
-
-Individua la riga `"": "", # bracket extension piece (non ricostruibile)` (circa riga 122) e aggiungi **dopo** l'ultima entry esistente della mappa (prima della `}`):
-
-```python
- "": "", # TeX large paren left
- "": "", # TeX large paren extension
- "": "", # TeX large paren right
- "": "", # TeX large paren right extension
- "": "", # TeX large bracket left
- "": "", # TeX large bracket extension
- "": "", # TeX brace top-left
- "": "", # TeX brace mid
- "": "", # TeX brace mid-right
- "": "", # TeX brace extension
- "": "", # TeX brace right
- "": "", # TeX bracket right large
- "": "", # TeX bracket right extension
- "": "", # TeX bracket right close
- "": "", # TeX integral large
- "": "", # TeX integral extension
- "": "", # TeX integral top
- "": "", # TeX radical top
- "": "", # TeX radical extension
- "": "", # TeX arrowhead
-```
-
-- [ ] **Step 2: Verifica che _SYMBOL_PUA_RE si aggiorni automaticamente**
-
-```bash
-.venv/bin/python -c "
-from conversione._pipeline.transforms import _SYMBOL_PUA_MAP, _SYMBOL_PUA_RE
-pua_chars = ['', '', '', '']
-for c in pua_chars:
- assert c in _SYMBOL_PUA_MAP, f'Manca {repr(c)}'
- assert _SYMBOL_PUA_RE.search(c), f'Regex non cattura {repr(c)}'
-print(f'OK — {len(_SYMBOL_PUA_MAP)} PUA chars mappati')
-"
-```
-
-Atteso: `OK — N PUA chars mappati` (N > 90)
-
-- [ ] **Step 3: Verifica sostituzione su testo di esempio**
-
-```bash
-.venv/bin/python -c "
-from conversione._pipeline.transforms import apply_transforms
-testo = 'Sia x = f(n) e n la parentesi grande.'
-pulito, stats = apply_transforms(testo)
-assert '' not in pulito
-assert '' not in pulito
-print('Testo pulito:', repr(pulito))
-print('PUA corretti:', stats['n_simboli_pua_corretti'])
-"
-```
-
-Atteso: nessun PUA nel testo pulito, `n_simboli_pua_corretti` > 0.
-
-- [ ] **Step 4: Commit**
-
-```bash
-git add conversione/_pipeline/transforms.py
-git commit -m "feat(transforms): aggiungi PUA bracket TeX U+F8EB-F8FE alla mappa simboli"
-```
-
----
-
-## Task 3: Nuova trasformazione `_t_math_header_demotion`
-
-**Files:**
-- Modify: `conversione/_pipeline/transforms.py`
-
-Demota a testo semplice gli header `##`/`###` che sono enunciati di esercizi o formule lunghe (non titoli di sezione reali).
-
-**Criteri di demozione** (almeno uno tra math e exercise deve valere):
-- Livello `##` o `###`
-- Lunghezza testo (senza `#`) > 100 caratteri
-- `math`: ≥ 3 simboli matematici nell'header (da set: `=`, `+`, `∈`, `∀`, `∃`, `≤`, `≥`, `∞`, `∑`, `∫`, `∂`, `→`, `↔`, `⊂`, `⊃`, `∩`, `∪`, lettere greche Unicode U+03B1–U+03C9 e U+0391–U+03A9)
-- `exercise`: matcha pattern traccia (`\b(Si dimostri|Si calcoli|Si provi|Si trovi|Trovare|Find|Prove|Show that|Compute|Calculate|Dimostrare|Verificare)\b`)
-
-**Output**: rimuove `#+ `. Se la riga inizia con `N. ` (numero + punto), converte in `**N.** resto`. Altrimenti testo plain.
-
-- [ ] **Step 1: Aggiungi costante regex a livello di modulo** (dopo le costanti esistenti, prima di `_SYMBOL_PUA_MAP`)
-
-Trova la riga `_VERSE_NUM_RE = re.compile(` (circa riga 160) e aggiungi **dopo**:
-
-```python
-_MATH_SYMBOLS_RE = re.compile(
- r"[=+∈∀∃≤≥∞∑∫∂→↔⊂⊃∩∪αβγδεζηθικλμνξοπρστυφχψωΑΒΓΔΕΖΗΘΙΚΛΜΝΞΟΠΡΣΤΥΦΧΨΩ]"
-)
-_EXERCISE_TRIGGER_RE = re.compile(
- r"\b(Si dimostri|Si calcoli|Si provi|Si trovi|Trovare|Find|Prove|Show that"
- r"|Compute|Calculate|Dimostrare|Verificare)\b",
- re.IGNORECASE,
-)
-_MATH_HDR_RE = re.compile(r"^(#{2,3})\s+(.+)$")
-_NUMBERED_PREFIX_RE = re.compile(r"^(\d+(?:\.\d+)*[.)])\s+(.+)$", re.DOTALL)
-```
-
-- [ ] **Step 2: Aggiungi la funzione `_t_math_header_demotion`** (prima dell'orchestratore `apply_transforms`)
-
-Trova la riga `# ─── Orchestratore` e aggiungi **prima**:
-
-```python
-def _t_math_header_demotion(text: str) -> tuple[str, int]:
- lines = text.split("\n")
- result, count = [], 0
- for line in lines:
- m = _MATH_HDR_RE.match(line)
- if not m:
- result.append(line)
- continue
- body = m.group(2)
- if len(body) <= 100:
- result.append(line)
- continue
- has_math = len(_MATH_SYMBOLS_RE.findall(body)) >= 3
- has_exercise = bool(_EXERCISE_TRIGGER_RE.search(body))
- if not (has_math or has_exercise):
- result.append(line)
- continue
- nm = _NUMBERED_PREFIX_RE.match(body)
- if nm:
- result.append(f"**{nm.group(1)}** {nm.group(2)}")
- else:
- result.append(body)
- count += 1
- return "\n".join(result), count
-```
-
-- [ ] **Step 3: Registra la trasformazione in `_transforms`**
-
-Nell'orchestratore `apply_transforms`, trova la riga:
-
-```python
- ("n_garbage_headers_rimossi", _t_remove_garbage_headers),
-```
-
-e aggiungi **dopo**:
-
-```python
- ("n_formula_headers_demotati", _t_math_header_demotion),
-```
-
-- [ ] **Step 4: Aggiungi la stat key al print in `runner.py`**
-
-Trova in `conversione/_pipeline/runner.py` il blocco di print delle statistiche (dopo `apply_transforms`) e aggiungi:
-
-```python
- print(f" Formula-hdr demotati: {t['n_formula_headers_demotati']}")
-```
-
-- [ ] **Step 5: Verifica su caso sintetico**
-
-```bash
-.venv/bin/python -c "
-from conversione._pipeline.transforms import apply_transforms
-
-# Caso 1: header esercizio lungo → deve essere demotato
-testo = '### 3. Si dimostri la formula per le equazioni di secondo grado ax^2 + bx + c = 0 e si analizzi il segno del discriminante b^2 - 4ac per tutti i valori reali.'
-pulito, stats = apply_transforms(testo)
-assert '###' not in pulito, f'Header non demotato: {pulito!r}'
-print('Caso 1 OK:', pulito[:80])
-
-# Caso 2: header titolo corto → NON deve essere demotato
-testo2 = '### Teorema di Cauchy'
-pulito2, _ = apply_transforms(testo2)
-assert '###' in pulito2, f'Header legittimo demotato: {pulito2!r}'
-print('Caso 2 OK:', pulito2)
-
-# Caso 3: header con molti simboli math + lungo → demotato
-testo3 = '### Sia f: R→R tale che ∀x∈R si abbia f(x) = ∑_{n=0}^{∞} aₙxⁿ con ∫f dx = g(x) + C per ogni x∈[a,b].'
-pulito3, stats3 = apply_transforms(testo3)
-print('Caso 3:', '###' not in pulito3, stats3.get('n_formula_headers_demotati'))
-
-print('Stats:', stats.get('n_formula_headers_demotati'))
-"
-```
-
-Atteso: Caso 1 e 3 demotati, Caso 2 intatto.
-
-- [ ] **Step 6: Commit**
-
-```bash
-git add conversione/_pipeline/transforms.py conversione/_pipeline/runner.py
-git commit -m "feat(transforms): aggiungi _t_math_header_demotion per header esercizi e formule"
-```
-
----
-
-## Task 4: `report.py` — metrica `formula_headers_residui`
-
-**Files:**
-- Modify: `conversione/_pipeline/report.py`
-
-- [ ] **Step 1: Aggiungi funzione di scan formula-header e integrala nel report**
-
-Nella funzione `build_report()`, dopo la definizione di `_scan()` (circa riga 53), aggiungi:
-
-```python
- def _scan_formula_headers(max_n: int = 10) -> list[dict]:
- _math_sym = re.compile(
- r"[=+∈∀∃≤≥∞∑∫∂→↔⊂⊃∩∪αβγδεζηθικλμνξοπρστυφχψωΑΒΓΔΕΖΗΘΙΚΛΜΝΞΟΠΡΣΤΥΦΧΨΩ]"
- )
- _ex_trigger = re.compile(
- r"\b(Si dimostri|Si calcoli|Si provi|Si trovi|Trovare|Find|Prove|Show that"
- r"|Compute|Calculate|Dimostrare|Verificare)\b",
- re.IGNORECASE,
- )
- hits = []
- for i, line in enumerate(text_lines):
- m = re.match(r"^(#{2,3})\s+(.+)$", line)
- if not m:
- continue
- body = m.group(2)
- if len(body) <= 100:
- continue
- has_math = len(_math_sym.findall(body)) >= 3
- has_ex = bool(_ex_trigger.search(body))
- if has_math or has_ex:
- hits.append({"riga": i + 1, "testo": line.strip()[:120]})
- if len(hits) >= max_n:
- break
- return hits
-```
-
-- [ ] **Step 2: Aggiungi la metrica ai `residui`**
-
-Trova nel dict `residui` la riga:
-
-```python
- "pua_markers": _scan(r'[-]'),
-```
-
-e aggiungi **dopo**:
-
-```python
- "formula_headers": _scan_formula_headers(),
-```
-
-Poi nel dict principale `report["residui"]`, trova la riga:
-
-```python
- "pua_markers_esempi": residui["pua_markers"],
-```
-
-e aggiungi **dopo**:
-
-```python
- "formula_headers": len(residui["formula_headers"]),
- "formula_headers_esempi": residui["formula_headers"],
-```
-
-- [ ] **Step 3: Verifica**
-
-```bash
-.venv/bin/python -c "
-import json
-from pathlib import Path
-from conversione._pipeline.report import build_report
-from conversione._pipeline.transforms import apply_transforms
-
-testo = open('conversione/analisi1/raw.md').read()
-clean, t = apply_transforms(testo)
-from conversione._pipeline.structure import analyze
-
-tmp = Path('/tmp/test_report')
-tmp.mkdir(exist_ok=True)
-(tmp / 'clean.md').write_text(clean)
-profile = analyze(tmp / 'clean.md')
-rp = build_report('test', tmp, clean, t, profile, 5.0)
-r = json.loads(rp.read_text())
-print('formula_headers residui:', r['residui']['formula_headers'])
-print('formula_headers esempi:', len(r['residui']['formula_headers_esempi']))
-"
-```
-
-Atteso: count numerico (può essere 0 se la demozione ha funzionato bene), nessun errore.
-
-- [ ] **Step 4: Commit**
-
-```bash
-git add conversione/_pipeline/report.py
-git commit -m "feat(report): aggiungi metrica formula_headers_residui"
-```
-
----
-
-## Task 5: `validator.py` — penalità formula headers
-
-**Files:**
-- Modify: `conversione/_pipeline/validator.py`
-
-- [ ] **Step 1: Aggiungi la penalità in `_score()`**
-
-Trova in `_score()` la riga:
-
-```python
- _pen("pua_markers", 2, 20, "caratteri PUA font Symbol")
-```
-
-e aggiungi **dopo**:
-
-```python
- _pen("formula_headers", 3, 15, "formula/esercizio come header")
-```
-
-- [ ] **Step 2: Aggiungi colonna `fhdr` nell'output tabellare di `validate()`**
-
-Trova in `validate()` la riga che costruisce `header`:
-
-```python
- header = (
- f"{'stem':<{col}}"
- f"{'h2':>4}{'h3':>5} "
- f"{'strategia':<18}"
- f"{'bare':>5}{'corte':>6}{'lunghe':>7}"
- f"{'btk':>5}{'br':>4}{'enc':>4}{'url':>4}"
- f"{'med':>6}"
- f" {'voto':>4} grade"
- )
-```
-
-Sostituiscila con:
-
-```python
- header = (
- f"{'stem':<{col}}"
- f"{'h2':>4}{'h3':>5} "
- f"{'strategia':<18}"
- f"{'bare':>5}{'corte':>6}{'lunghe':>7}"
- f"{'btk':>5}{'br':>4}{'enc':>4}{'url':>4}{'fhdr':>5}"
- f"{'med':>6}"
- f" {'voto':>4} grade"
- )
-```
-
-Trova il `print(...)` dentro il loop `for r in rows:` e aggiungi `fhdr`:
-
-```python
- print(
- f"{r['stem']:<{col}}"
- f"{st.get('n_h2', 0):>4}"
- f"{st.get('n_h3', 0):>5} "
- f"{st.get('strategia_chunking','?'):<18}"
- f"{an.get('bare_headers', 0):>5}"
- f"{an.get('short_sections', 0):>6}"
- f"{an.get('long_sections', 0):>7}"
- f"{res.get('backtick', 0):>5}"
- f"{res.get('br_inline', 0):>4}"
- f"{res.get('simboli_encoding', 0):>4}"
- f"{res.get('url', 0):>4}"
- f"{res.get('formula_headers', 0):>5}"
- f"{dist.get('mediana', 0):>6}"
- f" {s:>4} {_grade(s)}"
- )
-```
-
-Aggiorna anche la riga finale `print("\nColonne: ...")`:
-
-```python
- print(
- "\nColonne: bare=header vuoti corte=sez<150ch lunghe=sez>1500ch "
- "btk=backtick br=
inline enc=simboli encoding fhdr=formula-header med=mediana chars\n"
- )
-```
-
-- [ ] **Step 3: Verifica**
-
-```bash
-.venv/bin/python -c "
-from conversione._pipeline.validator import _score
-r = {'structure': {'livello_struttura': 3}, 'anomalie': {}, 'residui': {'formula_headers': 5}}
-score, detail = _score(r)
-print(score, detail)
-assert any('formula' in d for d in detail), 'Penalità formula non applicata'
-print('OK')
-"
-```
-
-Atteso: penalità `formula/esercizio come header ×5 −15` nel detail.
-
-- [ ] **Step 4: Commit**
-
-```bash
-git add conversione/_pipeline/validator.py
-git commit -m "feat(validator): aggiungi penalità formula_headers, colonna fhdr nel report"
-```
-
----
-
-## Task 6: Test di integrazione su analisi1
-
-- [ ] **Step 1: Riesegui la pipeline su analisi1**
-
-```bash
-.venv/bin/python conversione/ --stem analisi1 --force 2>&1
-```
-
-Atteso: completamento senza errori, print `Formula-hdr demotati: N` visibile.
-
-- [ ] **Step 2: Valida e confronta con il report precedente**
-
-```bash
-.venv/bin/python conversione/ validate analisi1 --detail
-```
-
-Confronta con il vecchio voto del `report.json` originale. Il voto deve essere ≥ al precedente.
-
-- [ ] **Step 3: Verifica riduzione PUA bracket**
-
-```bash
-python3 -c "
-import json
-r = json.load(open('conversione/analisi1/report.json'))
-pua = r['residui']['pua_markers']
-fhdr = r['residui'].get('formula_headers', 'N/A')
-print(f'PUA residui: {pua} (era 10+ prima)')
-print(f'Formula headers residui: {fhdr}')
-"
-```
-
-Atteso: `pua_markers` ridotto rispetto al run precedente (era 10 nel report originale).
-
-- [ ] **Step 4: Commit finale se tutto OK**
-
-```bash
-git add conversione/analisi1/
-git commit -m "chore: rigenera output analisi1 con pipeline ottimizzata"
-```
diff --git a/docs/superpowers/specs/2026-04-30-pipeline-ottimizzazione-design.md b/docs/superpowers/specs/2026-04-30-pipeline-ottimizzazione-design.md
deleted file mode 100644
index 698a7cb..0000000
--- a/docs/superpowers/specs/2026-04-30-pipeline-ottimizzazione-design.md
+++ /dev/null
@@ -1,80 +0,0 @@
-# Pipeline ottimizzazione — Design Spec
-*2026-04-30*
-
-## Obiettivo
-Eliminare la necessità di revisione manuale del `clean.md` per tutti i tipi di PDF (accademici/matematici, giuridici, tecnici) ottimizzando i parametri di opendataloader-pdf e aggiungendo trasformazioni mirate.
-
-## Scope
-Nessun hybrid backend. Solo Java + trasformazioni Python.
-
----
-
-## 1. `converter.py` — Parametri adattivi
-
-### 1.1 Rilevamento PDF taggato
-Funzione `_is_tagged_pdf(pdf_path) -> bool` usando PyMuPDF (`fitz`):
-```python
-doc = fitz.open(str(pdf_path))
-tagged = "StructTreeRoot" in doc.pdf_catalog()
-doc.close()
-```
-
-### 1.2 Nuovi parametri fissi (tutti i PDF)
-- `table_method="cluster"` — tabelle senza bordi visibili
-- `content_safety_off=["tiny", "hidden-ocg"]` — evita filtraggio di footnote e layer OCG
-
-### 1.3 Parametro condizionale
-- `use_struct_tree=tagged` — attivo solo se il PDF è taggato
-
-Una sola conversione Java, zero overhead per PDF non taggati.
-
----
-
-## 2. `transforms.py` — Due aggiunte
-
-### 2.1 PUA bracket TeX (U+F8EB–F8F8)
-Aggiunge al `_SYMBOL_PUA_MAP` i glifoni bracket di Computer Modern font che appaiono come PUA:
-`U+F8EB, U+F8EC, U+F8ED, U+F8EE, U+F8EF, U+F8F0, U+F8F1, U+F8F2, U+F8F3, U+F8F4, U+F8F5, U+F8F6, U+F8F7, U+F8F8, U+F8F9, U+F8FA, U+F8FB, U+F8FC, U+F8FD, U+F8FE`
-→ tutti mappati a `""` (pezzi di parentesi non ricostruibili come singolo glifo)
-
-Il `_SYMBOL_PUA_RE` si aggiorna automaticamente essendo costruito dalla mappa.
-
-### 2.2 Nuova trasformazione `_t_math_header_demotion`
-Demota a testo semplice gli header `##`/`###` che sono in realtà enunciati di esercizi o formule lunghe.
-
-**Criteri di demozione** (tutti devono valere):
-- Livello `##` o `###`
-- Lunghezza testo > 100 caratteri
-- Almeno uno tra:
- - ≥ 3 simboli matematici (`=`, `+`, `∈`, `∀`, `∃`, `≤`, `≥`, `∞`, lettere greche Unicode, `lim`, `sup`, `inf`, `∑`, `∫`)
- - Matcha pattern traccia esercizio: `(Si dimostri|Si calcoli|Si provi|Si trovi|Trovare|Find|Prove|Show|Compute|Calculate)\b`
-
-**Output**: rimuove `#+ ` iniziale. Se numerata (`N. testo`), converte in `**N.** testo`. Altrimenti testo plain.
-
-**Posizione in `_transforms`**: gruppo "Rifinitura", dopo `_t_garbage_headers`.
-
-**Stat key**: `n_formula_headers_demotati`
-
----
-
-## 3. `report.py` — Nuova metrica residua
-
-`build_report()` aggiunge contatore `formula_headers_residui`:
-- Conta header `##`/`###` nel `clean.md` finale che superano ancora i criteri math (sopra)
-- Mostra fino a 3 esempi in `formula_headers_esempi`
-
----
-
-## 4. `validator.py` — Nuova penalità
-
-| Problema | Penalità | Cap |
-|----------|----------|-----|
-| Formula/esercizio come header residuo | −3/cad | −15 |
-
----
-
-## File modificati
-1. `conversione/_pipeline/converter.py` — `_is_tagged_pdf()` + nuovi parametri
-2. `conversione/_pipeline/transforms.py` — PUA map + `_t_math_header_demotion`
-3. `conversione/_pipeline/report.py` — `formula_headers_residui`
-4. `conversione/_pipeline/validator.py` — nuova penalità