diff --git a/conversione/pipeline.py b/conversione/pipeline.py
index 03cde62..783f3d7 100644
--- a/conversione/pipeline.py
+++ b/conversione/pipeline.py
@@ -31,6 +31,7 @@ import subprocess
import sys
import tempfile
from datetime import datetime
+from functools import partial
from pathlib import Path
@@ -340,52 +341,29 @@ def _extract_article_headers(text: str) -> tuple[str, int]:
return text, count
-def apply_transforms(text: str) -> tuple[str, dict]:
- """
- Applica le trasformazioni strutturali al Markdown grezzo.
- Restituisce (testo_modificato, statistiche).
- """
- stats = {
- "toc_rimosso": False,
- "n_immagini_rimosse": 0,
- "n_accenti_corretti": 0,
- "n_moltiplicazioni_corrette": 0,
- "n_micro_corretti": 0,
- "n_br_rimossi": 0,
- "n_formule_rimossi": 0,
- "n_garbage_headers_rimossi": 0,
- "n_frontmatter_rimossi": 0,
- "n_dotleader_rimossi": 0,
- "n_header_concat_fixati": 0,
- "n_articoli_estratti": 0,
- "n_ambienti_matematici": 0,
- "n_titoli_uniti": 0,
- "n_header_allcaps": 0,
- "n_sezioni_numerate": 0,
- "n_paragrafi_uniti": 0,
- "n_tabsep_rimossi": 0,
- }
+# ─── [3a] Funzioni di trasformazione ─────────────────────────────────────────
- # 0. Rimuovi riferimenti immagini (artefatti opendataloader-pdf)
- stats["n_immagini_rimosse"] = len(re.findall(r"!\[[^\]]*\]\([^)]*\)", text))
+def _t_remove_images(text: str) -> tuple[str, int]:
+ n = len(re.findall(r"!\[[^\]]*\]\([^)]*\)", text))
text = re.sub(r"!\[[^\]]*\]\([^)]*\)\s*", "", text)
+ return text, n
- # 0_br. Rimuovi tag
residui da tabelle e blocchi formula PDF
- # Nelle celle di tabella produce spazio; nel testo inline elimina rumore.
- stats["n_br_rimossi"] = len(re.findall(r"
", text, re.IGNORECASE))
+
+def _t_fix_br(text: str) -> tuple[str, int]:
+ n = len(re.findall(r"
", text, re.IGNORECASE))
text = re.sub(r"
\s*", " ", text, flags=re.IGNORECASE)
+ return text, n
- # 0_tabsep. Rimuovi separatori tabella PDF: "| |" (riga vuota) e "|---|" (separatore).
- # Nascono da tabelle non strutturate nel PDF. Rimossi PRIMA del merge paragrafi
- # (step 5) altrimenti "|---|" viene fuso con il paragrafo successivo producendo
- # righe tipo "|---| Una caratterizzazione analoga...".
- _pat_tabsep = re.compile(r"(?m)^\|\s*\|\s*$|^\|---\|?\s*$")
- stats["n_tabsep_rimossi"] = len(_pat_tabsep.findall(text))
- text = _pat_tabsep.sub("", text)
- # 0a. Fix artefatti backtick da PDF LaTeX: `e→è, e`→è, sar`a→sarà, ecc.
- # I PDF prodotti da LaTeX estraggono gli accenti gravi come backtick separati
- # dalla vocale accentata. Esempi: "`e" → "è", "puo`" → "può", "sar`a" → "sarà"
+def _t_fix_tabsep(text: str) -> tuple[str, int]:
+ _pat = re.compile(r"(?m)^\|\s*\|\s*$|^\|---\|?\s*$")
+ n = len(_pat.findall(text))
+ text = _pat.sub("", text)
+ return text, n
+
+
+def _t_fix_accents(text: str) -> tuple[str, int]:
+ """Fix artefatti backtick da PDF LaTeX: `e→è, e`→è, sar`a→sarà, ecc."""
_ACCENT_MAP = {
"e": "è", "E": "È", "a": "à", "A": "À",
"u": "ù", "U": "Ù", "i": "ì", "I": "Ì", "o": "ò", "O": "Ò",
@@ -393,73 +371,61 @@ def apply_transforms(text: str) -> tuple[str, dict]:
n_bt_before = text.count("`")
text = re.sub(r"`([eEaAuUiIoO])", lambda m: _ACCENT_MAP[m.group(1)], text)
text = re.sub(r"([eEaAuUiIoO])`", lambda m: _ACCENT_MAP[m.group(1)], text)
- stats["n_accenti_corretti"] = n_bt_before - text.count("`")
-
+ n_accenti = n_bt_before - text.count("`")
# Backtick orfani: artefatti LaTeX rimasti dopo la correzione vocale
- # (es. "propriet`" da "proprietà", "continuit`" da "continuità").
- # In testi PDF non esistono backtick legittimi → rimozione sicura.
n_bt_orfani = text.count("`")
if n_bt_orfani:
text = re.sub(r"`", "", text)
- stats["n_accenti_corretti"] += n_bt_orfani
+ n_accenti += n_bt_orfani
+ return text, n_accenti
- # 0a2. Fix segno di moltiplicazione "→× (encoding font PDF non-standard)
- # Esempi: 2"107 → 2×107, 2"(10-2 m)3 → 2×(10-2 m)3
- # Lookbehind SOLO su cifra: evita falsi positivi tipo t1"t0 (→ limite)
- # o h"hf (→ differenza) dove la lettera prima della " non indica prodotto.
- _n_cross = len(re.findall(r'(?<=[0-9])"(?=[0-9(])', text))
+
+def _t_fix_multiplication(text: str) -> tuple[str, int]:
+ """Fix segno di moltiplicazione "→× (encoding font PDF non-standard)."""
+ n = len(re.findall(r'(?<=[0-9])"(?=[0-9(])', text))
text = re.sub(r'(?<=[0-9])"(?=[0-9(])', '×', text)
- stats["n_moltiplicazioni_corrette"] = _n_cross
+ return text, n
- # 0a3. Fix prefisso micro !→µ prima di unità SI note
- # "1 !m" → "1 µm", "1 !A" → "1 µA", "3 !s-1" → "3 µs-1"
- # Pattern stretto: cifra + spazio opzionale + ! + lettera unità SI a scelta ristretta.
- # Non tocca "4! steradianti" (spazio dopo !) né "mol!K" (non preceduto da cifra).
+
+def _t_fix_micro(text: str) -> tuple[str, int]:
+ """Fix prefisso micro !→µ prima di unità SI note."""
_SI_UNITS_RE = r'[mAsgVWFHTKNJClΩ]'
- _n_micro = len(re.findall(rf'\d\s*!(?={_SI_UNITS_RE})', text))
+ n = len(re.findall(rf'\d\s*!(?={_SI_UNITS_RE})', text))
text = re.sub(rf'(\d)\s*!({_SI_UNITS_RE})', r'\1 µ\2', text)
- stats["n_micro_corretti"] = _n_micro
+ return text, n
- # 0a4. Rimuovi label formule inline [N.M] — es. [3.4], [10.7], [5.25]
- # Non aggiungono valore semantico per il RAG; restano come rumore numerico.
- # Preserva [N] senza punto (riferimenti bibliografici/note legittime).
- n_form_before = len(re.findall(r"\[\d+\.\d+\]", text))
+
+def _t_remove_formula_labels(text: str) -> tuple[str, int]:
+ """Rimuovi label formule inline [N.M] — es. [3.4], [10.7]."""
+ n = len(re.findall(r"\[\d+\.\d+\]", text))
text = re.sub(r"\s*\[\d+\.\d+\]\s*", " ", text)
- stats["n_formule_rimossi"] = n_form_before
+ return text, n
- # 0b_pre. Rimuovi righe con dot-leader (voci di indice/sommario)
- # Esempi: "- 1.1 Alfabeto greco . . . . . . 1", "3.4 Continuità . . . . 205"
- # Pattern: almeno 3 occorrenze di ". " consecutive nella riga
- # Cattura sia ". . . ." (spazi) sia "......." (punti continui, tipici dei TOC PDF)
+
+def _t_remove_dotleaders(text: str) -> tuple[str, int]:
+ """Rimuovi righe con dot-leader e numerali romani isolati (footer TOC)."""
_DOTLEADER_RE = r"^[^\n]*(?:(?:\. ){3,}|\.{4,})[^\n]*$"
- stats["n_dotleader_rimossi"] = len(
- re.findall(_DOTLEADER_RE, text, re.MULTILINE)
- )
+ n = len(re.findall(_DOTLEADER_RE, text, re.MULTILINE))
text = re.sub(_DOTLEADER_RE, "", text, flags=re.MULTILINE)
-
- # 0b_pre2. Rimuovi righe che sono solo numerali romani (indicatori di pagina TOC)
- # Esempi: "i", "ii", "iii", "iv", "v" su riga isolata (footer pagine indice LaTeX)
- # Questi impedirebbero al transform 9 di rimuovere le entry TOC rimaste senza corpo.
text = re.sub(
r"(?m)^(i{1,3}|iv|vi{0,3}|ix|xi{0,2}|x)$",
"",
text,
flags=re.IGNORECASE,
)
+ return text, n
- # Flag documento: rilevamento sezioni esercizi (es. libri di testo accademici)
- # Usato per disabilitare transform 4b che convertirebbe i numeri degli esercizi in header.
- _has_exercise_sections = bool(re.search(r"\bEsercizi\b", text, re.IGNORECASE))
- # 0b. Fix header + body concatenati senza separatore
- # "##### 11 TitoloCorpodel testo..." → "##### 11 Titolo\n\nCorpo del testo..."
- def _fix_header_concat(m: re.Match) -> str:
+def _t_fix_header_concat(text: str) -> tuple[str, int]:
+ """Fix header + body concatenati senza separatore."""
+ count = 0
+
+ def _fix(m: re.Match) -> str:
+ nonlocal count
hashes = m.group(1)
full = m.group(2).strip()
if len(full) < 60:
return m.group(0)
- # Cerca split: lettera minuscola (incluse accentate) seguita da maiuscola
- # Salta i primi ~10 char per non spezzare il numero della sezione
skip = min(10, len(full) // 3)
split = re.search(r"(?<=[a-zàèéìíòóùúä])(?=[A-ZÀÈÉÌÍÒÓÙÚ])", full[skip:])
if split:
@@ -467,16 +433,17 @@ def apply_transforms(text: str) -> tuple[str, dict]:
title = full[:pos].strip()
body = full[pos:].strip()
if len(title) >= 5 and len(body) >= 15:
- stats["n_header_concat_fixati"] += 1
+ count += 1
return f"{hashes} {title}\n\n{body}"
return m.group(0)
- text = re.sub(r"^(#{2,6})\s+(.{40,})$", _fix_header_concat, text, flags=re.MULTILINE)
+ text = re.sub(r"^(#{2,6})\s+(.{40,})$", _fix, text, flags=re.MULTILINE)
+ return text, count
- # 0c. Estrai "Capitolo N: TITOLO" inline nel corpo del testo → ## header separato
- # "Capitolo 3: IL TITOLO DEL CAPITOLO - 16 Primo..." → "## Capitolo 3: ..."
- # "Capitolo 1 : TITOLO CAPITOLO" → "## Capitolo 1: ..."
- def _extract_capitolo(m: re.Match) -> str:
+
+def _t_extract_capitolo(text: str) -> tuple[str, int]:
+ """Estrai 'Capitolo N: TITOLO' inline nel corpo del testo → ## header."""
+ def _repl(m: re.Match) -> str:
num = m.group(1)
titolo = _sentence_case(m.group(2).strip().rstrip("- ").strip())
return f"\n\n## Capitolo {num}: {titolo}\n\n"
@@ -484,126 +451,124 @@ def apply_transforms(text: str) -> tuple[str, dict]:
text = re.sub(
r"\bCapitolo\s+(\d+)\s*[:\s]\s*([A-ZÀÈÉÌÍÒÓÙÚ\'L][A-ZÀÈÉÌÍÒÓÙÚ\s\'\.,\(\)]{5,80}?)"
r"(?=\s*[-–]\s*\d|\s*\n|\s*$)",
- _extract_capitolo,
+ _repl,
text,
)
+ return text, 0
- # 0d. Normalizza header di sezione a livello uniforme ###
- # "#### N Titolo" → "### N. Titolo" (numerati: aggiunge punto)
- # "#### B) Titolo" → "### B) Titolo" (lettera: solo cambio livello)
- # "#### " → rimosso (vuoti)
- text = re.sub(
- r"^#{3,6}\s*$",
- "",
- text,
- flags=re.MULTILINE,
- )
+
+def _t_normalize_header_levels(text: str) -> tuple[str, int]:
+ """Normalizza h4+ → h3; rimuove header vuoti."""
+ text = re.sub(r"^#{3,6}\s*$", "", text, flags=re.MULTILINE)
text = re.sub(
r"^(#{3,6})\s+(\d{1,3})\s+(.+)$",
lambda m: f"### {m.group(2)}. {m.group(3)}",
text,
flags=re.MULTILINE,
)
- text = re.sub(
- r"^#{4,6}\s+(.+)$",
- r"### \1",
- text,
- flags=re.MULTILINE,
- )
+ text = re.sub(r"^#{4,6}\s+(.+)$", r"### \1", text, flags=re.MULTILINE)
+ return text, 0
- # 0e. Converti voci articolo "- Art. N. Titolo. Corpo" → "### Art. N. Titolo.\n\nCorpo"
- # Eseguito dopo la promozione h4+ → h3 (0d) per non duplicare Art. già header.
- # Eseguito prima del merge paragrafi (5): il boundary ### previene la fusione.
- text, n_art = _extract_article_headers(text)
- stats["n_articoli_estratti"] = n_art
- # 1. Rimuovi **bold** negli header esistenti: ## **Titolo** → ## Titolo
+def _t_extract_articles(text: str) -> tuple[str, int]:
+ """Converti voci articolo '- Art. N.' → '### Art. N.'"""
+ return _extract_article_headers(text)
+
+
+def _t_remove_header_bold(text: str) -> tuple[str, int]:
+ """Rimuovi **bold** negli header esistenti."""
text = re.sub(
r"^(#{1,6})\s+\*\*(.+?)\*\*\s*$",
r"\1 \2",
text, flags=re.MULTILINE,
)
+ return text, 0
- # 1b. Normalizza header ALL-CAPS → sentence-case
- def _norm_allcaps_header(m: re.Match) -> str:
+
+def _t_normalize_allcaps_headers(text: str) -> tuple[str, int]:
+ """Normalizza header ALL-CAPS → sentence-case."""
+ def _norm(m: re.Match) -> str:
hashes, content = m.group(1), m.group(2).strip()
letters = [c for c in content if c.isalpha()]
if letters and all(c.isupper() for c in letters):
return f"{hashes} {_sentence_case(content)}"
return m.group(0)
- text = re.sub(r"^(#{1,6}) (.+)$", _norm_allcaps_header, text, flags=re.MULTILINE)
+ text = re.sub(r"^(#{1,6}) (.+)$", _norm, text, flags=re.MULTILINE)
+ return text, 0
- # 2. Rimuovi righe TOC: header "# Indice", "# Contents", ecc.
- # + le voci lista numeriche che seguono (TOC senza dot-leader, es. Nietzsche):
- # "- 1. Dei pregiudizi dei filosofi" → rimossa se viene subito dopo un header TOC.
- # Le voci con dot-leader sono già rimosse da 0b_pre.
- # Gli header rimasti senza corpo vengono poi eliminati dal transform 9.
+
+def _t_remove_toc(text: str) -> tuple[str, int]:
+ """Rimuovi header TOC e voci lista numerate che seguono."""
lines = text.split("\n")
new_lines = []
_in_toc = False
+ removed = False
for line in lines:
- bare = re.sub(r"^#+\s*", "", line.strip())
+ bare = re.sub(r"^#+\s*", "", line.strip())
first_word = bare.split(".")[0].strip().lower()
if first_word in _TOC_KEYWORDS:
- stats["toc_rimosso"] = True
+ removed = True
_in_toc = True
continue
if _in_toc:
- # Salta righe vuote e voci lista numeriche (- N. Titolo / - N Titolo)
if re.match(r"^\s*$", line) or re.match(r"^\s*[-*+]\s+\d", line):
continue
_in_toc = False
new_lines.append(line)
- text = "\n".join(new_lines)
+ return "\n".join(new_lines), 1 if removed else 0
- # 3. Converti righe ALL-CAPS standalone → ## header
+
+def _t_allcaps_to_headers(text: str) -> tuple[str, int]:
+ """Converti righe ALL-CAPS standalone → ## header."""
+ count = 0
blocks = text.split("\n\n")
new_blocks = []
for block in blocks:
stripped = block.strip()
if "\n" not in stripped and _is_allcaps_line(stripped):
new_blocks.append(_allcaps_to_header(stripped))
- stats["n_header_allcaps"] += 1
+ count += 1
else:
sub_lines = block.split("\n")
converted = []
for ln in sub_lines:
if _is_allcaps_line(ln) and len(ln.strip()) > 3:
converted.append(_allcaps_to_header(ln))
- stats["n_header_allcaps"] += 1
+ count += 1
else:
converted.append(ln)
new_blocks.append("\n".join(converted))
- text = "\n\n".join(new_blocks)
+ return "\n\n".join(new_blocks), count
+
+
+def _t_numbered_sections(text: str, has_exercises: bool = False) -> tuple[str, int]:
+ """Converti sezioni numerate 'N. testo' / '- N. testo' / '- N testo' → ### header."""
+ count = 0
- # 4. Converti sezioni numerate "N. testo" → "### N.\n\ntesto"
- # Guarda che il testo non sia una frase completa (es. esercizi numerati):
- # se termina con "." ed è più lungo di 40 caratteri, è probabilmente una frase,
- # non un titolo di sezione → lascia invariato.
def _num_repl(m: re.Match) -> str:
+ nonlocal count
content = m.group(2).strip()
if content.endswith(".") and len(content) > 40:
return m.group(0)
- stats["n_sezioni_numerate"] += 1
+ count += 1
return f"### {m.group(1)}.\n\n{content}"
text = re.sub(r"^(\d+)\.\s+(.+)$", _num_repl, text, flags=re.MULTILINE)
def _num_letter_repl(m: re.Match) -> str:
- stats["n_sezioni_numerate"] += 1
+ nonlocal count
+ count += 1
return f"### {m.group(1)}{m.group(2)}.\n\n{m.group(3).strip()}"
text = re.sub(r"^(\d+)\s*([a-z])\.\s+(.+)$", _num_letter_repl, text, flags=re.MULTILINE)
- # 4b. Converti "- N. testo" sezioni con punto → "### N.\n\ntesto"
- # "- 1. Testo del primo punto..." → "### 1.\n\nTesto del primo punto..."
- # Deve precedere 4c: "- N." ha il punto, "- N testo" no.
- # Disabilitato se il documento contiene sezioni "Esercizi": in quel caso i
- # "- N. testo" sono numerazioni di esercizi, non header di sezione.
- if not _has_exercise_sections:
+ # Disabilitato se il documento contiene sezioni "Esercizi": in quel caso i
+ # "- N. testo" sono numerazioni di esercizi, non header di sezione.
+ if not has_exercises:
def _aphorism_repl(m: re.Match) -> str:
- stats["n_sezioni_numerate"] += 1
+ nonlocal count
+ count += 1
return f"\n\n### {m.group(1)}.\n\n{m.group(2).strip()}"
text = re.sub(
@@ -613,22 +578,17 @@ def apply_transforms(text: str) -> tuple[str, dict]:
flags=re.MULTILINE,
)
- # 4c. Converti "- N testo" list item numerati → "### N.\n\ntesto"
- # "- 12 Titolo sezione Corpo della sezione..." → "### 12. Titolo sezione\n\nCorpo..."
- # Non tocca "- a) testo", "- 1) testo" (già gestiti come liste)
def _list_section_repl(m: re.Match) -> str:
+ nonlocal count
num = m.group(1)
content = m.group(2).strip()
- stats["n_sezioni_numerate"] += 1
- # Separa titolo da corpo: il titolo finisce dove una lettera minuscola
- # è seguita da spazio e maiuscola (confine fine-titolo / inizio-corpo)
+ count += 1
split = re.search(r"(?<=[a-zàèéìíòóùú])\s+(?=[A-ZÀÈÉÌÍÒÓÙÚ])", content)
if split and split.start() >= 3:
title = content[: split.start()].strip()
- body = content[split.end() :].strip()
+ body = content[split.end():].strip()
if len(body) >= 20:
return f"\n\n### {num}. {title}\n\n{body}"
- # Nessun body inline: il content è solo il titolo
return f"\n\n### {num}. {content}"
text = re.sub(
@@ -637,16 +597,20 @@ def apply_transforms(text: str) -> tuple[str, dict]:
text,
flags=re.MULTILINE,
)
+ return text, count
- # 4d. Converti ambienti matematici (Teorema/Definizione/...) → ### header
- # Eseguito prima del merge paragrafi (5) per sfruttare i blocchi intatti.
- text, n_math = _extract_math_environments(text)
- stats["n_ambienti_matematici"] = n_math
- # 5. Unisci paragrafi spezzati da salti pagina PDF
+def _t_extract_math(text: str) -> tuple[str, int]:
+ """Converti ambienti matematici (Teorema/Definizione/...) → ### header."""
+ return _extract_math_environments(text)
+
+
+def _t_merge_paragraphs(text: str) -> tuple[str, int]:
+ """Unisci paragrafi spezzati da salti pagina PDF."""
_SENTENCE_END = set(".?!»)\"'")
blocks = text.split("\n\n")
merged = []
+ count = 0
i = 0
while i < len(blocks):
b = blocks[i]
@@ -662,30 +626,38 @@ def apply_transforms(text: str) -> tuple[str, dict]:
break
b = stripped + " " + nxt
stripped = b.strip()
- stats["n_paragrafi_uniti"] += 1
+ count += 1
i += 1
merged.append(b)
i += 1
text = "\n\n".join(merged)
-
- # Secondo pass: rimuovi prefisso |---| eventualmente rimasto dopo il merge paragrafi
+ # Secondo pass: rimuovi prefisso |---| eventualmente rimasto dopo il merge
text = re.sub(r"(?m)^\|---\|\s*", "", text)
+ return text, count
- # 6. Normalizza whitespace multiplo interno alle righe
+
+def _t_normalize_whitespace(text: str) -> tuple[str, int]:
+ """Normalizza whitespace multiplo interno alle righe."""
lines = text.split("\n")
text = "\n".join(
re.sub(r" +", " ", line) if line.strip() else line
for line in lines
)
+ return text, 0
- # 7. Riduci righe vuote multiple a doppie
- text = re.sub(r"\n{3,}", "\n\n", text)
- # 8. Rimuovi righe che sono solo URL (watermark, footer di piattaforme)
- text = re.sub(r"(?m)^(https?://|www\.)\S+\s*$", "", text)
+def _t_collapse_blank_lines(text: str) -> tuple[str, int]:
+ """Riduci righe vuote multiple a doppie."""
+ return re.sub(r"\n{3,}", "\n\n", text), 0
- # 9. Rimuovi header senza corpo: header seguito solo da righe vuote e poi
- # da un altro header o dalla fine del testo (sezioni vuote / watermark)
+
+def _t_remove_urls(text: str) -> tuple[str, int]:
+ """Rimuovi righe che sono solo URL (watermark, footer di piattaforme)."""
+ return re.sub(r"(?m)^(https?://|www\.)\S+\s*$", "", text), 0
+
+
+def _t_remove_empty_headers(text: str) -> tuple[str, int]:
+ """Rimuovi header senza corpo (sezioni vuote / watermark)."""
blocks = re.split(r"\n{2,}", text)
cleaned = []
for i, block in enumerate(blocks):
@@ -693,48 +665,45 @@ def apply_transforms(text: str) -> tuple[str, dict]:
if re.match(r"^#{1,6} ", stripped) and "\n" not in stripped:
next_stripped = blocks[i + 1].strip() if i + 1 < len(blocks) else ""
if not next_stripped or re.match(r"^#{1,6} ", next_stripped):
- continue # header senza corpo → scarta
+ continue
cleaned.append(block)
- text = re.sub(r"\n{3,}", "\n\n", "\n\n".join(cleaned))
+ return re.sub(r"\n{3,}", "\n\n", "\n\n".join(cleaned)), 0
- # 9b. Fondi header numerici isolati con il sottotitolo breve successivo
- # "### N.\n\nSottotitolo" → "### N. Sottotitolo" (es. parti Nietzsche)
- text, n_titoli = _merge_title_headers(text)
- stats["n_titoli_uniti"] = n_titoli
- # 9c. Rimuovi garbage headers: header ### senza parole reali o con solo
- # abbreviazioni matematiche. Esempi: "### ( vm)", "### #", "### ! =",
- # "### (am)", "### 2. Il valore di hf si deter- mina risolvendo mg(h!hf)"
- # Questi nascono da espressioni matematiche scambiate per titoli di sezione.
- # Il corpo rimane nel testo e viene accorpato alla sezione precedente.
+def _t_merge_title_headers(text: str) -> tuple[str, int]:
+ """Fondi header numerici isolati con il sottotitolo breve successivo."""
+ return _merge_title_headers(text)
+
+
+def _t_remove_garbage_headers(text: str) -> tuple[str, int]:
+ """Rimuovi garbage headers: simboli, abbreviazioni matematiche, frammenti formula."""
def _is_garbage_header(content: str) -> bool:
- # Header con prefisso "..." — frammento di formula (es. "...Di", "...vi")
if content.lstrip().startswith("..."):
return True
- # Nessuna sequenza alfabetica ≥ 2 char
if not re.search(r"[A-Za-zÀ-ÿ]{2,}", content):
return True
- # Abbreviazione corta in parentesi opzionali: "(vm)", "( am)", "(am)"
if re.fullmatch(r"\(?\s*[A-Za-z]{1,4}\s*\)?", content.strip()):
return True
- # Header molto lungo (>60ch) con artefatti formula inline
if len(content) > 60 and re.search(r"[!%#]\w|\w[!%#]|\b\w+-\s*\w", content):
return True
return False
+ count = 0
lines = text.split("\n")
new_lines = []
for line in lines:
m = re.match(r"^#{1,6} (.+)$", line)
if m and _is_garbage_header(m.group(1)):
- stats["n_garbage_headers_rimossi"] += 1
+ count += 1
continue
new_lines.append(line)
text = "\n".join(new_lines)
text = re.sub(r"\n{3,}", "\n\n", text)
+ return text, count
- # 9d. Rimuovi sezioni frontmatter: header senza numero + corpo corto con
- # URL, email, affiliazione, copyright, edizione — metadati non-contenuto.
+
+def _t_remove_frontmatter(text: str) -> tuple[str, int]:
+ """Rimuovi sezioni frontmatter: URL, email, affiliazione, copyright."""
_FM_RE = re.compile(
r"https?://|www\.|@[A-Za-z]|\bUniversit[àa]\b|\bDipartimento\b|"
r"\bCopyright\b|\bLicenza\b|\bEdizione\b|"
@@ -743,20 +712,69 @@ def apply_transforms(text: str) -> tuple[str, dict]:
)
blocks = re.split(r"\n{2,}", text)
cleaned = []
+ count = 0
for i, block in enumerate(blocks):
stripped = block.strip()
if not re.match(r"^### ", stripped) or re.match(r"^### \d", stripped):
cleaned.append(block)
continue
body = blocks[i + 1].strip() if i + 1 < len(blocks) else ""
- is_fm_body = len(body) < 250 and _FM_RE.search(body)
- is_fm_hdr = _FM_RE.search(stripped)
+ is_fm_body = len(body) < 250 and _FM_RE.search(body)
+ is_fm_hdr = _FM_RE.search(stripped)
if is_fm_body or is_fm_hdr:
- stats["n_frontmatter_rimossi"] += 1
+ count += 1
continue
cleaned.append(block)
- text = re.sub(r"\n{3,}", "\n\n", "\n\n".join(cleaned))
+ return re.sub(r"\n{3,}", "\n\n", "\n\n".join(cleaned)), count
+
+# ─── [3b] Pipeline delle trasformazioni ──────────────────────────────────────
+
+def apply_transforms(text: str) -> tuple[str, dict]:
+ """
+ Applica le trasformazioni strutturali al Markdown grezzo.
+ Restituisce (testo_modificato, statistiche).
+ """
+ # Flag calcolato prima del loop: disabilita il transform 4b nei documenti
+ # con sezioni "Esercizi" (i "- N. testo" sarebbero numerazioni, non header).
+ _has_ex = bool(re.search(r"\bEsercizi\b", text, re.IGNORECASE))
+
+ _transforms: list[tuple[str | None, object]] = [
+ ("n_immagini_rimosse", _t_remove_images),
+ ("n_br_rimossi", _t_fix_br),
+ ("n_tabsep_rimossi", _t_fix_tabsep),
+ ("n_accenti_corretti", _t_fix_accents),
+ ("n_moltiplicazioni_corrette", _t_fix_multiplication),
+ ("n_micro_corretti", _t_fix_micro),
+ ("n_formule_rimossi", _t_remove_formula_labels),
+ ("n_dotleader_rimossi", _t_remove_dotleaders),
+ ("n_header_concat_fixati", _t_fix_header_concat),
+ (None, _t_extract_capitolo),
+ (None, _t_normalize_header_levels),
+ ("n_articoli_estratti", _t_extract_articles),
+ (None, _t_remove_header_bold),
+ (None, _t_normalize_allcaps_headers),
+ ("toc_rimosso", _t_remove_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_paragrafi_uniti", _t_merge_paragraphs),
+ (None, _t_normalize_whitespace),
+ (None, _t_collapse_blank_lines),
+ (None, _t_remove_urls),
+ (None, _t_remove_empty_headers),
+ ("n_titoli_uniti", _t_merge_title_headers),
+ ("n_garbage_headers_rimossi", _t_remove_garbage_headers),
+ ("n_frontmatter_rimossi", _t_remove_frontmatter),
+ ]
+
+ stats: dict = {}
+ for stat_key, fn in _transforms:
+ text, n = fn(text)
+ if stat_key:
+ stats[stat_key] = stats.get(stat_key, 0) + n
+
+ stats["toc_rimosso"] = bool(stats.get("toc_rimosso", 0))
return text, stats
@@ -802,6 +820,26 @@ def _split_sections(text: str, level: int) -> list[str]:
return [p for p in parts[1:] if p.strip()]
+def _parse_sections_with_body(text: str, level: int = 3) -> list[tuple[str, str]]:
+ """Restituisce lista di (header_line, body_text) per tutti gli header al livello dato."""
+ prefix = "#" * level + " "
+ lines = text.split("\n")
+ sections: list[tuple[str, str]] = []
+ cur_hdr: str | None = None
+ cur_body: list[str] = []
+ for line in lines:
+ if line.startswith(prefix):
+ if cur_hdr is not None:
+ sections.append((cur_hdr, "\n".join(cur_body).strip()))
+ cur_hdr = line
+ cur_body = []
+ elif cur_hdr is not None:
+ cur_body.append(line)
+ if cur_hdr is not None:
+ sections.append((cur_hdr, "\n".join(cur_body).strip()))
+ return sections
+
+
def analyze(md_path: Path) -> dict:
text = md_path.read_text(encoding="utf-8")
n_h1 = _count_headers(text, 1)
@@ -869,20 +907,7 @@ def build_report(
text_lines = clean_text.split("\n")
# ── Raccolta sezioni ### con corpo ────────────────────────────────────
- sections: list[tuple[str, str]] = []
- cur_hdr: str | None = None
- cur_body: list[str] = []
- for line in text_lines:
- if re.match(r"^### ", line):
- if cur_hdr is not None:
- sections.append((cur_hdr, "\n".join(cur_body).strip()))
- cur_hdr = line
- cur_body = []
- elif cur_hdr is not None:
- cur_body.append(line)
- if cur_hdr is not None:
- sections.append((cur_hdr, "\n".join(cur_body).strip()))
-
+ sections = _parse_sections_with_body(clean_text, 3)
lengths = [len(body) for _, body in sections]
# ── Distribuzione lunghezze ───────────────────────────────────────────
@@ -901,9 +926,6 @@ def build_report(
}
# ── Anomalie ──────────────────────────────────────────────────────────
- # Header solo-numero senza corpo sostanziale: anomalia solo se il corpo
- # è vuoto o < 30 chars. Un body lungo è una sezione numerata legittima
- # (es. aforismi numerati dove il numero è l'identificatore della sezione).
bare_hdrs = [
{"header": hdr, "corpo_inizio": body[:120].replace("\n", " ")}
for hdr, body in sections