Amiamo i PDF. Hanno lo stesso aspetto su ogni dispositivo, si stampano magnificamente in qualsiasi dimensione e sono la cosa più vicina alla carta digitale che abbiamo. Ma ogni volta che qualcuno nel nostro team dice "estraiamo semplicemente i dati dal PDF", sentiamo un antico demone PostScript svegliarsi e sussurrare: “Sono nato per dipingere pixel, non per strutturare le tue righe.”
In questo articolo, spieghiamo perché i PDF sono ottimi per la presentazione e terribili per i dati. Daremo un'occhiata alle viscere di un PDF, costruiremo un piccolo esempio e poi proveremo (e falliremo, e riproveremo) a estrarre un estratto conto bancario fittizio. Alla fine, speriamo che smetterai di aspettarti che i PDF si comportino come CSV con un impermeabile.
L'ascendenza sbagliata
Il PDF non è cresciuto volendo essere un'API. È cresciuto volendo essere un driver di stampante che non ha mai dovuto incontrare la tua stampante. Il modello PDF è essenzialmente: una sequenza di istruzioni di disegno.
Ciò significa "disegna il testo qui, in questo font, a questa dimensione, poi spostati un po', poi disegna altro testo". Non significa "questa è una tabella con 5 colonne e un'intestazione". Non esiste un concetto nativo di righe, colonne o persino parole. Ci sono solo glifi posizionati a coordinate.
Se tieni questo a mente, tutto ciò che segue ha... beh, non senso, ma almeno sembra meno malvagio.
Un tour di 30 secondi in un PDF
All'interno di un PDF troverai:
Objects: blob numerati (dizionari, array, stream) che definiscono pagine, font, immagini, ecc.
Content streams: flussi di byte compressi contenenti i comandi di disegno per ogni pagina.
Text operators: cose come
BT(begin text),Tj/TJ(show text),Td/Tm(move the text matrix), ecc.Cross‑reference (xref):
L'elenco telefonico che dice a un lettore dove risiede ogni oggetto nel file.
Puoi aprire un semplice PDF in un editor di testo e strizzare gli occhi. Alcune parti saranno leggibili; altre sembreranno statiche perché sono stream compressi. È normale.
Un content stream giocattolo
Ecco uno snippet drasticamente semplificato (e sanificato) come potresti vederlo dopo aver decompresso il content stream di una pagina:

BT % Begin text object
/F1 12 Tf % Select font F1 at 12 points
72 720 Td % Move to position (72, 720)
(2024-05-01) Tj % Draw simple text "2024-05-01"
0 -20 Td % Move down 20 units
(VEGA PARKING - REF: 827492) Tj % Draw simple text "VEGA PARKING - REF: 827492"
0 -20 Td % Move down 20 units
[(1) -50 (2) -50 (3) -50 (4)] TJ % Draw "1234" with spacing
0 -20 Td % Move down 20 units
[(Amount:) -100 ($) -20 (1) -30 (2) -30 (3) -30 (.) -20 (4) -30 (5)] TJ
% Complex TJ: "Amount: $123.45" with kerning
0 -20 Td % Move down 20 units
(Regular text here) Tj % Simple text again
ET % End text object
Sono appena successe due cose importanti:
TjeTJdisegnano glifi, non "caratteri in una parola". A volte il font codifica i glifi in modi strani (codifiche personalizzate, subset). Non puoi dare per scontato che i byte corrispondano a Unicode.TJaccetta un array di stringhe e numeri. I numeri modificano la spaziatura (kerning). Le persone lo usano per rendere i numeri più belli. Il tuo estrattore ora deve calcolare le posizioni visive per ricostruire dove "1 2 3 4" atterrano effettivamente.
Perché il tuo estrattore viene ingannato
Esaminiamo tre trappole comuni che trasformano un "PDF facile" in un "weekend rovinato".
1) Il testo non è testo
Se un file incorpora un font subsetted (es. /F1+ABCDEE+Inter), la lettera visibile A potrebbe in realtà essere l'ID glifo 37 che questo font mappa ad A, ma il glifo 37 di un altro PDF potrebbe mappare a Ω. Se il font dimentica di includere una mappa /ToUnicode (o mente), l'estrazione grezza produce spazzatura. L'OCR non ti salverà se il testo è già composto da glifi vettoriali.
Sintomo: Vedi stringhe Tj come \x12\x7F\x03 e il tuo strumento restituisce felicemente .
Soluzione-ish: Euristiche + decodifica font + pregare che esista la mappa /ToUnicode. Altrimenti sei nel territorio della "computer vision".
2) Le parole sono un'illusione
Non ci sono spazi a meno che un glifo non sia letteralmente uno spazio. Molti PDF disegnano "CHAMPAGNE" come nove glifi indipendenti con spazi arbitrari. Se qualcosa è una parola o due colonne casualmente vicine è compito tuo dedurlo raggruppando le coordinate.
Sintomo: Il tuo "split on spaces" restituisce ['CH', 'AMP', 'AGNE'] o peggio, unisce due colonne in una parola mostruosa.
Soluzione-ish: Ricostruire l'ordine di lettura usando la matrice di testo e soglie di tolleranza per gli spazi X/Y. Aspettati di dover fare tuning per ogni documento.
3) I layout sono fiocchi di neve su misura
L'"Importo" della Banca A può essere allineato a destra a x=480; la Banca B usa una tabella; la Banca C renderizza ogni cifra individualmente con TJ per allineare i decimali. L'unica cosa coerente riguardo a "Descrizione / Data / Importo" è che è incoerente.
Sintomo: Il tuo parser basato su regole funziona su 9 PDF poi silenziosamente perde i decimali sul decimo.
Soluzione-ish: Separare il rendering dalla semantica. Costruire un modello visivo (box, linee, sequenze di testo) e poi una mappatura appresa da elementi visivi → campi. O mantenere template per emittente se il tuo piano a lungo termine include la tristezza.
Un piccolo esperimento: creare, poi estrarre
Per prima cosa, genereremo un estratto conto giocattolo. Qualsiasi libreria PDF va bene; la chiave è: renderizzeremo ciò che vedono gli umani, non "dati strutturati".
# Generate a one-page statement as humans see it
open_page(width=letter, height=letter)
draw_text("Sample Bank Statement", at=(x_left, y_top))
draw_text("Date", at=(x_date, y_head))
draw_text("Description", at=(x_desc, y_head))
draw_text_right("Amount", right_edge=x_amount_right, y=y_head)
for (date, desc, amount) in transactions:
draw_text(date, at=(x_date, y))
draw_text(desc, at=(x_desc, y))
# critical: render amount with digit-by-digit kerning (TJ)
draw_digits_with_spacing(amount, right_edge=x_amount_right, y=y)
y = y - line_height
save_pdf("sample.pdf")
Ora proviamo a estrarre. Dimostreremo tre strategie prima in pseudocodice (vedi Appendice per script eseguibili): "martello di stringhe", "camminata ingenua nel testo" e "layout-aware".
# Method 1: YOLO (string hunting)
bytes = read_file("sample.pdf")
visible = grep_text(bytes, patterns=["%PDF", "xref", "/Type", "/Page"])
print(visible) # may find container/meta tokens; page streams are compressed
# Method 2: Naive text walk
lines = []
for page in pdf_pages("sample.pdf"):
buf = ""
prev_y = None
for op in page.text_ops(): # yields chars in operator order
if op.type == "char":
if prev_y is not None and (prev_y - op.y) > y_tol:
lines.append(buf.strip()); buf = ""
buf += op.text
prev_y = op.y
elif op.type in {"space", "newline"}:
buf += " "
if buf: lines.append(buf.strip())
print(lines) # words mashed, columns merged, garbled text if ToUnicode is missing
# Method 3: Layout-aware graph
runs = []
for page in pdf_pages("sample.pdf"):
glyphs = [ (g.x, g.y, g.w, g.text) for g in page.glyphs() ]
lines = cluster_by_y(glyphs, tol=y_tol)
words = [ cluster_by_x(line, gap=x_tol) for line in lines ]
right_guide = infer_right_edge([w for line in words for w in line if looks_numeric(w)])
for line in words:
amount = snap_right_aligned(line, right_guide, tol=snap_tol)
date = first_token_like("YYYY-MM-DD", line)
desc = tokens_between(date, amount, line)
if date and amount:
runs.append({
"date": date.text,
"description": join(desc),
"amount": parse_locale_number(amount.text)
})
print({ "transactions": runs })
{"transactions": [
{"date": "2024-01-03", "description": "VEGA PARKING - REF: 827492", "amount": -3.45},
{"date": "2024-01-05", "description": "LUMINA PAYMENTS - Transfer ID: 14x8Nqm7", "amount": 796.60},
{"date": "2024-01-08", "description": "STELLAR TRANSPORT - Invoice F887", "amount": -63.36}
]}
Spoiler: Il Metodo 1 non estrae quasi nulla. Il Metodo 2 restituisce testo incomprensibile per quella riga /ToUnicode mancante e unisce le colonne. Il Metodo 3 è... finalmente utilizzabile, finché non provi il template di una seconda banca.
La boss fight dell'estratto conto
Facciamo finta che un cliente carichi tre diversi estratti conto bancari per il KYC. Sembrano uguali a una persona:
- Data, Descrizione, Importo.
- Gli importi negativi hanno un segno meno; i decimali usano una virgola perché... Europa.
Sotto il cofano, sono mostri completamente diversi:
- Emittente A usa ToUnicode e semplice
Tj. Modalità facile. - Emittente B disegna le cifre con
TJcome[ (1) -30 (2) -30 (3) -30 (,) -20 (4) ]così i decimali si allineano. Allineato a destra a x≈480. - Emittente C ha incorporato un font Type 3 con glifi personalizzati. Nessun ToUnicode. La virgola è in realtà l'ID glifo 17, che la tua libreria decodifica come
\x11.
Una pipeline deve:
- Decodificare i font quando possibile; ripiegare sull'OCR solo dove necessario.
- Ricostruire l'ordine di lettura tramite la geometria (non l'ordine del testo).
- Normalizzare i locale (
,vs.; spazi non-breaking; parentesi per i negativi). - Rilevare e correggere l'"insalata di cifre" da
TJcalcolando le posizioni effettive dei glifi.
Ecco il tipo di post-elaborazione che finisci per scrivere (pseudocodice):
# (placeholder) postprocess.py
for run in text_runs:
if looks_like_right_aligned_amount(run):
amount = stitch_digits_by_x(run.glyphs, tolerance=1.5)
amount = normalize_decimal_separators(amount)
yield {"amount": parse_money(amount), "x": run.right_x}
E no, non è una sola funzione. È un giardino di euristiche, ognuna adottata dopo una segnalazione di bug.
“Ma il mio funziona sul 95% dei file!”
Anche il nostro. Il PDF mediano va bene. La coda è dove vivono i team di compliance. La "long tail" include:
- Scansioni (bitmap) intervallate da testo vettoriale.
- PDF linearizzati dove gli oggetti vengono mescolati per lo streaming.
- Xref strani e object stream compressi.
- PDF taggati che fingono di avere una struttura (a volte utile! a volte menzogne!).
Non noti queste cose finché un cliente critico non usa esattamente quell'export da esattamente quel sistema bancario core.
Pensiero conclusivo → e come lo facciamo noi di Holofin
I PDF sono meravigliosi in ciò per cui sono stati progettati: mostrare in modo affidabile agli umani la stessa pagina. Se hai bisogno di dati, chiedi dati, CSV, JSON, XLSX. E quando la realtà dice "il regolatore vuole i PDF", hai bisogno di una pipeline che tratti i PDF come i piccoli programmi grafici che sono.
In Holofin, questa è letteralmente la nostra job description: trasformare PDF caotici e reali in dati strutturati, validati e pronti per la produzione, da export puliti a scansioni macchiate di caffè degli anni '90.
I nostri principi
- Struttura prima della semantica. Ricostruiamo prima la geometria (glifi → parole → righe → blocchi → tabelle) e solo allora assegniamo un significato. Questo evita bug del tipo "numeri giusti, intestazione sbagliata".
- Ancore ovunque. Ogni valore porta con sé numero di pagina, bounding box e stack di intestazioni in modo da poter cliccare per tornare alla fonte in fase di revisione/debug.
- Output deterministici. Forniamo valori coerenti e verificabili derivati dal contenuto del documento; unità e valute sono preservate come fornite dalla fonte.
- Vincoli > vibes. I totali devono sommare ai subtotali; i bilanci devono quadrare; le date devono essere plausibili. Quando le regole falliscono, proviamo strategie ed euristiche alternative.
Come appare su un estratto conto
Ricordi i nostri tre emittenti (Tj semplice, kerning con TJ, font subset senza ToUnicode)? Holofin li gestisce tramite:
- Estrazione del testo resiliente: Combiniamo la decodifica nativa del testo con il fallback OCR dove appropriato per garantire risultati coerenti tra font, codifiche e scansioni.
- Ricostruzione del layout geometry‑first: Ricostruiamo l'ordine di lettura, le righe e le colonne dalla geometria sulla pagina in modo che le differenze di formattazione non rompano il parsing.
- Interpretazione domain‑aware: Assegniamo la semantica con validazioni finanziarie (es. saldi e subtotali devono riconciliarsi) per prevenire valori plausibili-ma-errati.
- Output verificabile e revisionabile: Restituiamo dati strutturati con provenienza per supportare la revisione umana e la tracciabilità.
Perché questo è importante
In finanza, un errore di estrazione dell'uno percento non sembra un errore di battitura, sembra un cambiamento di valutazione. Progettiamo per l'accuratezza e la coerenza attraverso controlli a strati, riconciliazioni e audit trail completi, offrendo un'affidabilità di cui i sistemi a valle e i revisori possono fidarsi.
Cosa ottieni out-of-the-box
- Precisione zero‑shot del 97%+ su documenti finanziari comuni (e strumenti per rivedere l'ultima piccola percentuale).
- Elaborazione multi‑documento (es. un anno di estratti conto) in una singola chiamata API con output consolidato e normalizzato.
- Modalità debug con ground truth della fonte & audit trail per ogni valore.
- Enterprise‑ready REST API e UI web, costruite per la scalabilità e la sicurezza, con data retention configurabile (GDPR‑friendly di default).
Se questo risuona con le cicatrici del tuo parser PDF, parliamone.
Articoli correlati

Quando i documenti si ribellano
Pagina 1: Riepilogo conto, due colonne. Pagina 15: Stesso conto, tre colonne, nomi delle intestazioni diversi. Pagina 47: Una scansione con una macchia di caffè. Pagina 89: La pagina dei totali, che fa riferimento a transazioni estratte 70 pagine fa.

La traccia di audit invisibile
Un revisore apre il tuo file di esportazione, trova un saldo di chiusura di 47.500 € e recupera il PDF di origine. Pagina 3, angolo in basso a destra: 47.000 €. Numero diverso. "Da dove arriva la differenza? Chi l'ha modificata?"

HoloRecall: Mostrare, non raccontare
C'è un momento in ogni progetto di classificazione in cui osservi il modello sbagliare con sicurezza. Non un caso difficile. Non un caso limite ambiguo. Qualcosa che un umano risolverebbe in mezzo secondo senza pensare.