Compare commits
6 Commits
ab4036591f
...
3f4689e8fd
| Author | SHA1 | Date | |
|---|---|---|---|
| 3f4689e8fd | |||
| 2c0b7a462e | |||
| 6e755c0b6c | |||
| 9598209f12 | |||
| 64dc403e80 | |||
| afbf29514d |
+2
-2
@@ -27,11 +27,11 @@ __pycache__/
|
||||
Thumbs.db
|
||||
|
||||
|
||||
# Output conversione/ — generati da conversione/pipeline.py
|
||||
# Output conversione/ — generati dagli script
|
||||
conversione/*/
|
||||
!conversione/_pipeline/
|
||||
!conversione/_pipeline/transforms
|
||||
!conversione/_pipeline/**
|
||||
conversione/_pipeline/__pycache__/
|
||||
|
||||
# Output chunks/ — generati da chunks/chunker.py e chunks/verify_chunks.py
|
||||
chunks/*/
|
||||
|
||||
@@ -69,33 +69,41 @@ 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
|
||||
├── transforms.py # apply_transforms() + tutte le _t_* (~920 righe)
|
||||
├── 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()
|
||||
├── validator.py # validate() + _score() + _grade()
|
||||
├── _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.py` — cuore della pipeline
|
||||
### `_pipeline/extract.py` — fronte PDF
|
||||
|
||||
Contiene ~35 trasformazioni atomiche (`_t_*`) e l'orchestratore `apply_transforms(text) -> (text, stats)`. Le trasformazioni sono tenute in un unico file perché hanno dipendenze incrociate dense e il loro **ordine è semantico** — non modificarlo senza capire le dipendenze.
|
||||
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`.
|
||||
|
||||
Ordine logico dei gruppi (non separare):
|
||||
1. **Encoding** — PUA font Symbol, accenti backtick LaTeX, moltiplicazione, micro
|
||||
2. **Pulizia artefatti** — immagini, `<br>`, footnote superscript, URL, box symbol, righe ricorrenti, watermark
|
||||
3. **Struttura header** — fix header+body concatenati, Capitolo inline, normalizzazione livelli numerati, `####`→`###`, bold, ALL-CAPS
|
||||
4. **Costruzione struttura** — TOC rimosso, ALL-CAPS→`##`, sezioni numerata→`###`, ambienti matematici, articoli
|
||||
5. **Testo** — merge paragrafi spezzati, whitespace, blank lines, poesia, versi
|
||||
6. **Rifinitura** — header vuoti, garbage header, merge titoli isolati, frontmatter
|
||||
### `_pipeline/_apply.py` — cuore della pipeline
|
||||
|
||||
Costanti di modulo (compilate una volta): `_SYMBOL_PUA_MAP`, `_SYMBOL_PUA_RE`, `_TABSEP_RE`, `_FM_RE`, `_VERSE_NUM_RE`, `_NUMBERED_HDR_RE`, `_BIB_MARKERS_RE`, `_WATERMARK_RE`, `_SUPERSCRIPT_RE`.
|
||||
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, `<br>`, 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
|
||||
4. **Costruzione struttura** (`_structure.py`) — TOC rimosso, ALL-CAPS→`##`, sezioni numerate→`###`, ambienti matematici, articoli
|
||||
5. **Testo** (`_text.py`) — merge paragrafi spezzati, whitespace, blank lines, poesia, versi
|
||||
6. **Rifinitura** (`_finish.py`) — header vuoti, garbage header, merge titoli isolati, frontmatter
|
||||
|
||||
Flag automatico: se il testo contiene "Esercizi/Problems/Homework", `_t_numbered_sections` non converte `- N. testo` in header (sono numerazioni di esercizi, non titoli).
|
||||
|
||||
@@ -146,10 +154,11 @@ 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`.
|
||||
- Aggiungere la coppia `("stat_key", _t_nuova)` nella lista `_transforms` nel punto logicamente corretto (rispettare i gruppi sopra).
|
||||
- Compilare i pattern regex a livello di modulo come costanti, non dentro la funzione.
|
||||
- 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, mai dentro la funzione.
|
||||
- Testare con `.venv/bin/python conversione/ --stem <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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,109 @@
|
||||
"""Orchestratore: applica le trasformazioni in ordine semantico."""
|
||||
import re
|
||||
from functools import partial
|
||||
|
||||
from ._encoding import (
|
||||
_t_fix_symbol_font, _t_fix_accents,
|
||||
_t_fix_multiplication, _t_fix_micro,
|
||||
)
|
||||
from ._artifacts import (
|
||||
_t_remove_images, _t_fix_br, _t_fix_tabsep, _t_remove_footnotes,
|
||||
_t_remove_formula_labels, _t_remove_dotleaders, _t_remove_recurring_lines,
|
||||
_t_fix_math_symbols, _t_remove_watermarks, _t_remove_urls,
|
||||
_t_remove_page_markers, _t_remove_page_numbers, _t_remove_separators,
|
||||
)
|
||||
from ._headers import (
|
||||
_t_fix_header_concat, _t_extract_capitolo,
|
||||
_t_normalize_numbered_headings, _t_normalize_header_levels,
|
||||
_t_remove_header_bold, _t_normalize_allcaps_headers,
|
||||
)
|
||||
from ._structure import (
|
||||
_t_remove_toc, _t_remove_orphan_toc, _t_allcaps_to_headers,
|
||||
_t_numbered_sections, _t_promote_chapter_headers,
|
||||
_t_extract_math, _t_extract_articles,
|
||||
)
|
||||
from ._text import (
|
||||
_t_merge_paragraphs, _t_normalize_whitespace, _t_collapse_blank_lines,
|
||||
_t_restore_poetry_lines, _t_demote_verse_headers,
|
||||
)
|
||||
from ._finish import (
|
||||
_t_remove_empty_headers, _t_merge_title_headers,
|
||||
_t_remove_garbage_headers, _t_math_header_demotion,
|
||||
_t_remove_frontmatter,
|
||||
)
|
||||
|
||||
|
||||
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))
|
||||
|
||||
# (stat_key, fn, label)
|
||||
_transforms: list[tuple[str | None, object, str]] = [
|
||||
# 1. Encoding
|
||||
("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_page_markers_rimossi", _t_remove_page_markers, "rimozione page markers PDF"),
|
||||
("n_separatori_rimossi", _t_remove_separators, "rimozione separatori underscore"),
|
||||
("n_immagini_rimosse", _t_remove_images, "rimozione immagini"),
|
||||
("n_br_rimossi", _t_fix_br, "fix <br> 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"),
|
||||
("n_numeri_pagina_rimossi", _t_remove_page_numbers, "rimozione numeri pagina isolati"),
|
||||
# 3. Struttura header
|
||||
("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, "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_capitoli_promossi", _t_promote_chapter_headers, "promozione capitoli ### → ##"),
|
||||
("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, "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, lambda t: (re.sub(r"(?m)^(#{1,6}.+?)\s+pag\.\s*\d{1,4}\s*$", r"\1", t), 0), "strip pag.N dagli header"),
|
||||
(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 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
|
||||
+32
-11
@@ -3,7 +3,8 @@ 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,
|
||||
_PAGE_MARKER_RE, _STANDALONE_NUM_RE, _UNDERSCORE_SEP_RE,
|
||||
)
|
||||
|
||||
|
||||
@@ -47,15 +48,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 +98,30 @@ 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
|
||||
|
||||
|
||||
def _t_remove_page_markers(text: str) -> tuple[str, int]:
|
||||
"""Rimuove i marcatori <!-- page: N --> e i separatori --- adiacenti."""
|
||||
n = len(_PAGE_MARKER_RE.findall(text))
|
||||
# Rimuovi ---\n<!-- page: N --> come blocco unico (separatori di pagina PDF)
|
||||
text = re.sub(r"(?m)^---\s*\n<!-- page: \d+ -->\s*\n?", "", text)
|
||||
# Rimuovi eventuali <!-- page: N --> rimasti senza ---
|
||||
text = _PAGE_MARKER_RE.sub("", text)
|
||||
return text, n
|
||||
|
||||
|
||||
def _t_remove_page_numbers(text: str) -> tuple[str, int]:
|
||||
"""Rimuove numeri di pagina isolati (1-3 cifre su una riga solitaria)."""
|
||||
n = len(_STANDALONE_NUM_RE.findall(text))
|
||||
text = _STANDALONE_NUM_RE.sub("", text)
|
||||
return text, n
|
||||
|
||||
|
||||
def _t_remove_separators(text: str) -> tuple[str, int]:
|
||||
"""Rimuove linee di separazione formate solo da underscore (___...)."""
|
||||
n = len(_UNDERSCORE_SEP_RE.findall(text))
|
||||
text = _UNDERSCORE_SEP_RE.sub("", text)
|
||||
return text, n
|
||||
+9
-1
@@ -123,15 +123,19 @@ _NUMBERED_HDR_RE = re.compile(
|
||||
)
|
||||
_BIB_MARKERS_RE = re.compile(
|
||||
r'\b(pp?\.|vol\.|n\.\s*\d|ed\.|edn\.|ISBN|DOI|arXiv)\b'
|
||||
r'|\b(19|20)\d{2}\b',
|
||||
r'|\b(19|20)\d{2}\b'
|
||||
r'|\b(ibid\.?|ibidem|op\.\s*cit\.?|cit\.|cfr\.|ivi[,;\s])\b',
|
||||
re.IGNORECASE,
|
||||
)
|
||||
# Pattern autore accademico: iniziale maiuscola + cognome TUTTO-MAIUSCOLO (es. A. PAJNO, G. GUZZETTA)
|
||||
_FOOTNOTE_AUTHOR_RE = re.compile(r'(?<![A-Z])[A-Z]\.\s+[A-Z]{3,}')
|
||||
_WATERMARK_RE = re.compile(
|
||||
r"^(BOZZA|DRAFT|CONFIDENTIAL|RISERVATO|PROVVISORIO|SAMPLE|SPECIMEN"
|
||||
r"|DO NOT DISTRIBUTE|NON DISTRIBUIRE|COPY|COPIA)\s*$",
|
||||
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|"
|
||||
@@ -159,3 +163,7 @@ _TOC_ITEM_RE = re.compile(
|
||||
_TOC_HDR_WITH_PAGE_RE = re.compile(
|
||||
r"^#{1,3}\s+\d+\.?\s+.{3,60}\s+\d{1,4}$"
|
||||
)
|
||||
# Artefatti PDF: page markers e separatori
|
||||
_PAGE_MARKER_RE = re.compile(r"(?m)^<!-- page: \d+ -->\s*$")
|
||||
_STANDALONE_NUM_RE = re.compile(r"(?m)^(?:- )?\d{1,3}$")
|
||||
_UNDERSCORE_SEP_RE = re.compile(r"(?m)^_{4,}\s*$")
|
||||
+71
-7
@@ -2,7 +2,7 @@
|
||||
import re
|
||||
|
||||
from ._constants import (
|
||||
_TOC_KEYWORDS, _BIB_MARKERS_RE,
|
||||
_TOC_KEYWORDS, _BIB_MARKERS_RE, _FOOTNOTE_AUTHOR_RE,
|
||||
_TOC_ITEM_RE, _TOC_HDR_WITH_PAGE_RE,
|
||||
)
|
||||
from ._helpers import (
|
||||
@@ -28,6 +28,9 @@ def _t_remove_toc(text: str) -> tuple[str, int]:
|
||||
continue
|
||||
if re.match(r"^\s*[-*+]\s+.{2,70}\s+\d{1,3}\s*$", line):
|
||||
continue
|
||||
# Righe brevi con riferimento pagina (es. "Prefazione pag. 4")
|
||||
if re.match(r"^.{3,80}\s+pag\.\s*\d{1,4}\s*$", line.strip()):
|
||||
continue
|
||||
if len(line.strip()) > 200:
|
||||
_in_toc = False
|
||||
new_lines.append(line)
|
||||
@@ -118,10 +121,23 @@ def _t_numbered_sections(text: str, has_exercises: bool = False) -> tuple[str, i
|
||||
content = m.group(2).strip()
|
||||
if content.endswith(".") and len(content) > 40:
|
||||
return m.group(0)
|
||||
if _BIB_MARKERS_RE.search(content):
|
||||
# Paragrafo lungo: non è un titolo di sezione
|
||||
if len(content) > 130:
|
||||
return m.group(0)
|
||||
if _BIB_MARKERS_RE.search(content) or _FOOTNOTE_AUTHOR_RE.search(content):
|
||||
return m.group(0)
|
||||
count += 1
|
||||
return f"### {m.group(1)}.\n\n{content}"
|
||||
# Prova a separare titolo dal corpo alla prima transizione minusc→Maiusc
|
||||
split = re.search(
|
||||
r"(?<=[a-z\xe0\xe8\xe9\xec\xed\xf2\xf3\xf9\xfa])\s+"
|
||||
r"(?=[A-Z\xc0\xc8\xc9\xcc\xcd\xd2\xd3\xd9\xda])",
|
||||
content,
|
||||
)
|
||||
if split and 3 <= split.start() and len(content) - split.end() >= 40:
|
||||
title = content[: split.start()].strip()
|
||||
body = content[split.end():].strip()
|
||||
return f"### {m.group(1)}. {title}\n\n{body}"
|
||||
return f"### {m.group(1)}. {content}"
|
||||
|
||||
text = re.sub(r"^(\d+)\.\s+(.+)$", _num_repl, text, flags=re.MULTILINE)
|
||||
|
||||
@@ -136,13 +152,22 @@ def _t_numbered_sections(text: str, has_exercises: bool = False) -> tuple[str, i
|
||||
def _aphorism_repl(m: re.Match) -> str:
|
||||
nonlocal count
|
||||
content = m.group(2).strip()
|
||||
if _BIB_MARKERS_RE.search(content):
|
||||
if _BIB_MARKERS_RE.search(content) or _FOOTNOTE_AUTHOR_RE.search(content):
|
||||
return m.group(0)
|
||||
count += 1
|
||||
return f"\n\n### {m.group(1)}.\n\n{content}"
|
||||
split = re.search(
|
||||
r"(?<=[a-z\xe0\xe8\xe9\xec\xed\xf2\xf3\xf9\xfa])\s+"
|
||||
r"(?=[A-Z\xc0\xc8\xc9\xcc\xcd\xd2\xd3\xd9\xda])",
|
||||
content,
|
||||
)
|
||||
if split and 3 <= split.start() and len(content) - split.end() >= 40:
|
||||
title = content[: split.start()].strip()
|
||||
body = content[split.end():].strip()
|
||||
return f"\n\n### {m.group(1)}. {title}\n\n{body}"
|
||||
return f"\n\n### {m.group(1)}. {content}"
|
||||
|
||||
text = re.sub(
|
||||
r"^-\s+(\d{1,3})\.\s+(.{10,})$",
|
||||
r"^-[ \t]+(\d{1,3})\.[ \t]+(.{10,})$",
|
||||
_aphorism_repl,
|
||||
text,
|
||||
flags=re.MULTILINE,
|
||||
@@ -152,7 +177,7 @@ def _t_numbered_sections(text: str, has_exercises: bool = False) -> tuple[str, i
|
||||
nonlocal count
|
||||
num = m.group(1)
|
||||
content = m.group(2).strip()
|
||||
if _BIB_MARKERS_RE.search(content):
|
||||
if _BIB_MARKERS_RE.search(content) or _FOOTNOTE_AUTHOR_RE.search(content):
|
||||
return m.group(0)
|
||||
count += 1
|
||||
split = re.search(
|
||||
@@ -176,6 +201,45 @@ def _t_numbered_sections(text: str, has_exercises: bool = False) -> tuple[str, i
|
||||
return text, count
|
||||
|
||||
|
||||
def _t_promote_chapter_headers(text: str) -> tuple[str, int]:
|
||||
"""
|
||||
Promuove ### N. Titolo → ## N. Titolo quando sembrano capitoli principali.
|
||||
Condizioni: ≥3 headers ### con numero 1–50, nessun ## già presente,
|
||||
numeri di capitolo sequenziali e NON duplicati.
|
||||
Numeri duplicati indicano una raccolta multi-articolo: non promuovere.
|
||||
"""
|
||||
if re.search(r"^## \d", text, re.MULTILINE):
|
||||
return text, 0
|
||||
|
||||
pattern = re.compile(r"^### (\d+)\. (.+)$", re.MULTILINE)
|
||||
matches = list(pattern.finditer(text))
|
||||
chapter_matches = [m for m in matches if int(m.group(1)) <= 50]
|
||||
|
||||
if len(chapter_matches) < 3:
|
||||
return text, 0
|
||||
|
||||
chapter_nums_list = [int(m.group(1)) for m in chapter_matches]
|
||||
|
||||
# Se qualche numero appare ≥3 volte è una raccolta multi-articolo: non promuovere
|
||||
num_counter: dict[int, int] = {}
|
||||
for n in chapter_nums_list:
|
||||
num_counter[n] = num_counter.get(n, 0) + 1
|
||||
if max(num_counter.values()) >= 3:
|
||||
return text, 0
|
||||
|
||||
chapter_nums = set(chapter_nums_list)
|
||||
count = 0
|
||||
|
||||
def _repl(m: re.Match) -> str:
|
||||
nonlocal count
|
||||
if int(m.group(1)) in chapter_nums:
|
||||
count += 1
|
||||
return f"## {m.group(1)}. {m.group(2)}"
|
||||
return m.group(0)
|
||||
|
||||
return pattern.sub(_repl, text), count
|
||||
|
||||
|
||||
def _t_extract_math(text: str) -> tuple[str, int]:
|
||||
return _extract_math_environments(text)
|
||||
|
||||
@@ -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}"
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -0,0 +1,177 @@
|
||||
"""Estrazione PDF: verifica dipendenze, validazione, metadati, conversione → raw Markdown."""
|
||||
import re
|
||||
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}"
|
||||
|
||||
|
||||
# ─── Metadati PDF ────────────────────────────────────────────────────────────
|
||||
|
||||
def extract_metadata(pdf_path: Path) -> dict:
|
||||
"""
|
||||
Estrae title, author, year e page count dal PDF tramite fitz.
|
||||
Restituisce un dict con chiavi sempre presenti (stringa vuota se assenti).
|
||||
"""
|
||||
try:
|
||||
import fitz
|
||||
doc = fitz.open(str(pdf_path))
|
||||
meta = doc.metadata
|
||||
pages = len(doc)
|
||||
doc.close()
|
||||
|
||||
def _clean(s: str) -> str:
|
||||
return s.strip() if s else ""
|
||||
|
||||
year = ""
|
||||
creation = meta.get("creationDate", "")
|
||||
m = re.match(r"D:(\d{4})", creation)
|
||||
if m:
|
||||
year = m.group(1)
|
||||
|
||||
return {
|
||||
"source": pdf_path.name,
|
||||
"title": _clean(meta.get("title", "")),
|
||||
"author": _clean(meta.get("author", "")),
|
||||
"year": year,
|
||||
"pages": pages,
|
||||
}
|
||||
except Exception:
|
||||
return {"source": pdf_path.name, "title": "", "author": "", "year": "", "pages": 0}
|
||||
|
||||
|
||||
# ─── 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)
|
||||
markdown_page_separator → inserisce separatore + marker pagina tra pagine
|
||||
replace_invalid_chars → sostituisce caratteri non validi con spazio
|
||||
"""
|
||||
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,
|
||||
markdown_page_separator="\n\n---\n<!-- page: %page-number% -->\n\n",
|
||||
replace_invalid_chars=" ",
|
||||
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
|
||||
@@ -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:
|
||||
|
||||
+112
-30
@@ -1,17 +1,66 @@
|
||||
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, extract_metadata
|
||||
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"}
|
||||
|
||||
|
||||
def _build_frontmatter(meta: dict) -> str:
|
||||
lines = ["---", f"source: {meta['source']}"]
|
||||
if meta["title"]:
|
||||
lines.append(f'title: "{meta["title"]}"')
|
||||
if meta["author"]:
|
||||
lines.append(f'author: "{meta["author"]}"')
|
||||
if meta["year"]:
|
||||
lines.append(f"year: {meta['year']}")
|
||||
if meta["pages"]:
|
||||
lines.append(f"pages: {meta['pages']}")
|
||||
lines += ["---", ""]
|
||||
return "\n".join(lines) + "\n"
|
||||
|
||||
_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"
|
||||
out_dir = project_root / "conversione" / stem
|
||||
@@ -27,57 +76,87 @@ def run(stem: str, project_root: Path, force: bool) -> bool:
|
||||
print(f" (usa --force per rieseguire)")
|
||||
return True
|
||||
|
||||
# [1] Validazione
|
||||
# [1] Validazione + metadati
|
||||
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
|
||||
print(f" ✅ {msg}")
|
||||
meta = extract_metadata(pdf_path)
|
||||
if meta["title"]:
|
||||
print(f" Titolo: {meta['title']}")
|
||||
if meta["author"]:
|
||||
print(f" Autore: {meta['author']}")
|
||||
|
||||
# [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()
|
||||
clean_text = _build_frontmatter(meta) + clean_text
|
||||
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" <br> 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 +183,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
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
"""Package transforms: pipeline di pulizia strutturale per Markdown RAG."""
|
||||
from ._apply import apply_transforms
|
||||
|
||||
__all__ = ["apply_transforms"]
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,96 +0,0 @@
|
||||
"""Orchestratore: applica le trasformazioni in ordine semantico."""
|
||||
import re
|
||||
from functools import partial
|
||||
|
||||
from ._encoding import (
|
||||
_t_fix_symbol_font, _t_fix_accents,
|
||||
_t_fix_multiplication, _t_fix_micro,
|
||||
)
|
||||
from ._artifacts import (
|
||||
_t_remove_images, _t_fix_br, _t_fix_tabsep, _t_remove_footnotes,
|
||||
_t_remove_formula_labels, _t_remove_dotleaders, _t_remove_recurring_lines,
|
||||
_t_fix_math_symbols, _t_remove_watermarks, _t_remove_urls,
|
||||
)
|
||||
from ._headers import (
|
||||
_t_fix_header_concat, _t_extract_capitolo,
|
||||
_t_normalize_numbered_headings, _t_normalize_header_levels,
|
||||
_t_remove_header_bold, _t_normalize_allcaps_headers,
|
||||
)
|
||||
from ._structure import (
|
||||
_t_remove_toc, _t_remove_orphan_toc, _t_allcaps_to_headers,
|
||||
_t_numbered_sections, _t_extract_math, _t_extract_articles,
|
||||
)
|
||||
from ._text import (
|
||||
_t_merge_paragraphs, _t_normalize_whitespace, _t_collapse_blank_lines,
|
||||
_t_restore_poetry_lines, _t_demote_verse_headers,
|
||||
)
|
||||
from ._finish import (
|
||||
_t_remove_empty_headers, _t_merge_title_headers,
|
||||
_t_remove_garbage_headers, _t_math_header_demotion,
|
||||
_t_remove_frontmatter,
|
||||
)
|
||||
|
||||
|
||||
def apply_transforms(text: str) -> 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.
|
||||
"""
|
||||
_has_ex = bool(re.search(r"\b(Esercizi|Exercises|Problems|Homework)\b", text, re.IGNORECASE))
|
||||
|
||||
_transforms: list[tuple[str | None, object]] = [
|
||||
# 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),
|
||||
# 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),
|
||||
# 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),
|
||||
# 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),
|
||||
# 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),
|
||||
# 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),
|
||||
]
|
||||
|
||||
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
|
||||
+24
-6
@@ -4,10 +4,30 @@ set -euo pipefail
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
cd "$SCRIPT_DIR"
|
||||
|
||||
mapfile -t dirs < <(find . -maxdepth 1 -mindepth 1 -type d | sort)
|
||||
STEM="${1:-}"
|
||||
|
||||
if [[ -n "$STEM" ]]; then
|
||||
# ── Modalità singolo stem ─────────────────────────────────────────────
|
||||
target="./$STEM"
|
||||
if [[ ! -d "$target" ]]; then
|
||||
echo "Errore: cartella '$STEM' non trovata in conversione/."
|
||||
exit 1
|
||||
fi
|
||||
rm -rf "$target"
|
||||
echo "Rimossa: conversione/$STEM/"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# ── Modalità batch: tutti gli output (escluse cartelle infrastruttura) ────
|
||||
mapfile -t dirs < <(
|
||||
find . -maxdepth 1 -mindepth 1 -type d \
|
||||
! -name '_*' \
|
||||
! -name '__*' \
|
||||
| sort
|
||||
)
|
||||
|
||||
if [[ ${#dirs[@]} -eq 0 ]]; then
|
||||
echo "Nessuna cartella da cancellare."
|
||||
echo "Nessuna cartella di output da cancellare."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
@@ -16,10 +36,8 @@ for d in "${dirs[@]}"; do
|
||||
echo " $d"
|
||||
done
|
||||
|
||||
if [[ "${1:-}" != "-f" ]]; then
|
||||
read -r -p "Confermi? [s/N] " answer
|
||||
[[ "$answer" =~ ^[sS]$ ]] || { echo "Annullato."; exit 0; }
|
||||
fi
|
||||
read -r -p "Confermi? [s/N] " answer
|
||||
[[ "$answer" =~ ^[sS]$ ]] || { echo "Annullato."; exit 0; }
|
||||
|
||||
for d in "${dirs[@]}"; do
|
||||
rm -rf "$d"
|
||||
|
||||
@@ -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=<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"
|
||||
```
|
||||
@@ -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à
|
||||
Reference in New Issue
Block a user