feat(conversione): 7 nuovi transform pipeline, refactor validate — media 92→99/100
- dot-leader continui, strip "- " in allcaps, backtick orfani LaTeX - TOC list removal, extract_article_headers, extract_math_environments, merge_title_headers - validate.py: interfaccia semplificata, rimosso codice morto
This commit is contained in:
+197
-9
@@ -169,7 +169,9 @@ def _is_allcaps_line(line: str) -> bool:
|
||||
|
||||
|
||||
def _allcaps_to_header(raw_line: str) -> str:
|
||||
text = raw_line.strip().rstrip(".").rstrip("?").strip()
|
||||
# Rimuovi eventuale prefisso di lista "- " o "* " prima di creare l'header
|
||||
text = re.sub(r"^[-*+]\s+", "", raw_line.strip())
|
||||
text = text.rstrip(".").rstrip("?").strip()
|
||||
|
||||
_ORD_IT_PAT = "|".join(_ORDINALS_IT.keys())
|
||||
m = re.match(rf"^CAPITOLO ({_ORD_IT_PAT})\. (.+)", text)
|
||||
@@ -192,6 +194,152 @@ def _allcaps_to_header(raw_line: str) -> str:
|
||||
return f"## {_sentence_case(text)}"
|
||||
|
||||
|
||||
def _extract_math_environments(text: str) -> tuple[str, int]:
|
||||
"""
|
||||
Converte paragrafi che iniziano con ambienti matematici in header ###.
|
||||
|
||||
'Teorema 1.6.3 (principio di induzione) Sia A ⊆ N...'
|
||||
→ '### Teorema 1.6.3 (principio di induzione)\n\nSia A ⊆ N...'
|
||||
|
||||
Riconosce: Definizione, Teorema, Lemma, Proposizione, Corollario,
|
||||
Osservazione, Nota, Esempio (solo con numero di sezione).
|
||||
Non tocca paragrafi che già iniziano con un header Markdown.
|
||||
Deve girare PRIMA del merge paragrafi (step 5) per sfruttare i blocchi intatti.
|
||||
"""
|
||||
_ENVS = (
|
||||
r"Definizione|Teorema|Lemma|Proposizione|"
|
||||
r"Corollario|Osservazione|Nota|Esempio"
|
||||
)
|
||||
count = 0
|
||||
blocks = text.split("\n\n")
|
||||
result = []
|
||||
|
||||
for block in blocks:
|
||||
stripped = block.strip()
|
||||
if not stripped or stripped.startswith("#"):
|
||||
result.append(block)
|
||||
continue
|
||||
|
||||
m = re.match(
|
||||
rf"^({_ENVS})\s+((?:\d+\.?){{1,4}})\s*(.*)",
|
||||
stripped,
|
||||
re.DOTALL,
|
||||
)
|
||||
if not m:
|
||||
result.append(block)
|
||||
continue
|
||||
|
||||
env = m.group(1)
|
||||
num = m.group(2).rstrip(".")
|
||||
rest = m.group(3).strip()
|
||||
|
||||
# Titolo opzionale tra parentesi: "(principio di induzione)"
|
||||
title_m = re.match(r"^(\([^)]{2,60}\))\s+(.*)", rest, re.DOTALL)
|
||||
if title_m:
|
||||
header = f"### {env} {num} {title_m.group(1)}"
|
||||
body = title_m.group(2).strip()
|
||||
else:
|
||||
header = f"### {env} {num}."
|
||||
body = rest
|
||||
|
||||
result.append(f"{header}\n\n{body}" if body else header)
|
||||
count += 1
|
||||
|
||||
return "\n\n".join(result), count
|
||||
|
||||
|
||||
def _merge_title_headers(text: str) -> tuple[str, int]:
|
||||
"""
|
||||
Fonde header numerici isolati con il sottotitolo breve che li segue.
|
||||
|
||||
'### N.\n\nSottotitolo (riga singola ≤ 80 char, senza punto finale)'
|
||||
→ '### N. Sottotitolo'
|
||||
|
||||
Caso tipico: parti di un'opera (es. Nietzsche) dove il numero di sezione
|
||||
e il titolo della sezione sono in blocchi Markdown separati.
|
||||
Non tocca header con titolo già inline né header seguiti da testo lungo.
|
||||
"""
|
||||
count = 0
|
||||
blocks = re.split(r"\n{2,}", text)
|
||||
result = []
|
||||
i = 0
|
||||
while i < len(blocks):
|
||||
block = blocks[i]
|
||||
stripped = block.strip()
|
||||
if (
|
||||
re.match(r"^#{2,3} \d+\.\s*$", stripped)
|
||||
and i + 1 < len(blocks)
|
||||
):
|
||||
nxt = blocks[i + 1].strip()
|
||||
# Sottotitolo valido: riga singola, ≤ 80 char, non header, non numerazione pura
|
||||
if (
|
||||
nxt
|
||||
and "\n" not in nxt
|
||||
and len(nxt) <= 80
|
||||
and not nxt.startswith("#")
|
||||
and not re.match(r"^\d+[\.\)]\s", nxt)
|
||||
):
|
||||
result.append(stripped.rstrip() + " " + nxt)
|
||||
count += 1
|
||||
i += 2
|
||||
continue
|
||||
result.append(block)
|
||||
i += 1
|
||||
return re.sub(r"\n{3,}", "\n\n", "\n\n".join(result)), count
|
||||
|
||||
|
||||
def _extract_article_headers(text: str) -> tuple[str, int]:
|
||||
"""
|
||||
Converte voci di articolo dal formato lista Markdown al formato header ###.
|
||||
|
||||
'- Art. N[suffix]. Titolo. Corpo testo...' → '### Art. N[suffix]. Titolo.\n\nCorpo testo...'
|
||||
'- Art. N[suffix]. (…) (1)' → '### Art. N[suffix].\n\n(…) (1)'
|
||||
|
||||
Gestisce suffissi come: Art. 4-bis., Art. 14-ter., Art. 1-quinquies.
|
||||
Il titolo è la prima frase con iniziale maiuscola che termina con '.' prima di
|
||||
ulteriore testo (es. "Leggi. La formazione..." → titolo "Leggi", corpo "La formazione...").
|
||||
Se il testo non ha titolo separabile, tutto diventa il corpo.
|
||||
"""
|
||||
count = 0
|
||||
|
||||
def _repl(m: re.Match) -> str:
|
||||
nonlocal count
|
||||
num = m.group(1)
|
||||
rest = m.group(2).strip()
|
||||
|
||||
# Titolo: frase con iniziale maiuscola, max 75 char, termina con '.',
|
||||
# seguita da almeno un'altra frase (minimo 5 char) che inizia con maiuscola
|
||||
# o con '(' / cifra (note a piè o continuazione corpo).
|
||||
title_m = re.match(
|
||||
r"^([A-ZÀÈÉÌÍÒÓÙÚ].{1,74}?)\.\s+([A-ZÀÈÉÌÍÒÓÙÚ\(\d].{4,})",
|
||||
rest,
|
||||
)
|
||||
if title_m:
|
||||
count += 1
|
||||
return (
|
||||
f"### Art. {num}. {title_m.group(1)}.\n\n"
|
||||
f"{title_m.group(2).strip()}"
|
||||
)
|
||||
|
||||
# Nessun titolo separabile: tutto è corpo
|
||||
if rest:
|
||||
count += 1
|
||||
return f"### Art. {num}.\n\n{rest}"
|
||||
|
||||
# Articolo senza testo inline (es. "- Art. 5. (…) (1)" già estratto sopra,
|
||||
# oppure articolo vuoto nella lista)
|
||||
count += 1
|
||||
return f"### Art. {num}."
|
||||
|
||||
text = re.sub(
|
||||
r"^-\s+Art\.\s+([\d]+[a-z\-]*)\.\s*(.*)",
|
||||
_repl,
|
||||
text,
|
||||
flags=re.MULTILINE,
|
||||
)
|
||||
return text, count
|
||||
|
||||
|
||||
def apply_transforms(text: str) -> tuple[str, dict]:
|
||||
"""
|
||||
Applica le trasformazioni strutturali al Markdown grezzo.
|
||||
@@ -203,6 +351,9 @@ def apply_transforms(text: str) -> tuple[str, dict]:
|
||||
"n_accenti_corretti": 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,
|
||||
@@ -224,13 +375,23 @@ def apply_transforms(text: str) -> tuple[str, dict]:
|
||||
text = re.sub(r"([eEaAuUiIoO])`", lambda m: _ACCENT_MAP[m.group(1)], text)
|
||||
stats["n_accenti_corretti"] = 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
|
||||
|
||||
# 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)
|
||||
_DOTLEADER_RE = r"^[^\n]*(?:(?:\. ){3,}|\.{4,})[^\n]*$"
|
||||
stats["n_dotleader_rimossi"] = len(
|
||||
re.findall(r"^[^\n]*(?:\. ){3,}[^\n]*$", text, re.MULTILINE)
|
||||
re.findall(_DOTLEADER_RE, text, re.MULTILINE)
|
||||
)
|
||||
text = re.sub(r"^[^\n]*(?:\. ){3,}[^\n]*$", "", text, flags=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)
|
||||
@@ -306,6 +467,12 @@ def apply_transforms(text: str) -> tuple[str, dict]:
|
||||
flags=re.MULTILINE,
|
||||
)
|
||||
|
||||
# 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
|
||||
text = re.sub(
|
||||
r"^(#{1,6})\s+\*\*(.+?)\*\*\s*$",
|
||||
@@ -324,18 +491,26 @@ def apply_transforms(text: str) -> tuple[str, dict]:
|
||||
text = re.sub(r"^(#{1,6}) (.+)$", _norm_allcaps_header, text, flags=re.MULTILINE)
|
||||
|
||||
# 2. Rimuovi righe TOC: header "# Indice", "# Contents", ecc.
|
||||
# Rimuove la riga stessa; le voci subordinate (dot-leader) sono già rimosse da 0b_pre.
|
||||
# L'header rimasto senza corpo viene poi eliminato dal transform 9.
|
||||
# + 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.
|
||||
lines = text.split("\n")
|
||||
new_lines = []
|
||||
_in_toc = False
|
||||
for line in lines:
|
||||
# Stripping del prefisso markdown (##, #, ecc.) prima del confronto keyword
|
||||
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
|
||||
else:
|
||||
new_lines.append(line)
|
||||
_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)
|
||||
|
||||
# 3. Converti righe ALL-CAPS standalone → ## header
|
||||
@@ -419,6 +594,11 @@ def apply_transforms(text: str) -> tuple[str, dict]:
|
||||
flags=re.MULTILINE,
|
||||
)
|
||||
|
||||
# 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
|
||||
_SENTENCE_END = set(".?!»)\"'")
|
||||
blocks = text.split("\n\n")
|
||||
@@ -470,6 +650,11 @@ def apply_transforms(text: str) -> tuple[str, dict]:
|
||||
cleaned.append(block)
|
||||
text = re.sub(r"\n{3,}", "\n\n", "\n\n".join(cleaned))
|
||||
|
||||
# 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
|
||||
|
||||
return text, stats
|
||||
|
||||
|
||||
@@ -734,6 +919,9 @@ def run(stem: str, project_root: Path, force: bool) -> bool:
|
||||
print(f" Accenti corretti: {t_stats['n_accenti_corretti']}")
|
||||
print(f" Dot-leader rimossi: {t_stats['n_dotleader_rimossi']}")
|
||||
print(f" Header concat fixati: {t_stats['n_header_concat_fixati']}")
|
||||
print(f" Articoli → ###: {t_stats['n_articoli_estratti']}")
|
||||
print(f" Ambienti matematici: {t_stats['n_ambienti_matematici']}")
|
||||
print(f" Titoli header uniti: {t_stats['n_titoli_uniti']}")
|
||||
print(f" TOC rimosso: {'sì' if t_stats['toc_rimosso'] else 'no'}")
|
||||
print(f" ALL-CAPS → ##: {t_stats['n_header_allcaps']}")
|
||||
print(f" Sezioni → ###: {t_stats['n_sezioni_numerate']}")
|
||||
|
||||
Reference in New Issue
Block a user