Nous adorons les PDF. Ils s'affichent de la même manière sur tous les appareils, s'impriment magnifiquement à n'importe quelle taille, et sont ce qui se rapproche le plus du papier numérique. Mais chaque fois que quelqu'un de notre équipe dit « extrayons simplement les données du PDF », nous sentons un ancien démon PostScript se réveiller et chuchoter : « Je suis né pour peindre des pixels, pas pour structurer vos lignes. »
Dans cet article, nous expliquons pourquoi les PDF sont géniaux pour la présentation et terribles pour les données. Nous jetterons un coup d'œil dans les entrailles d'un PDF, construirons un petit exemple, puis essaierons (et échouerons, et réessaierons) d'extraire un relevé bancaire fictif. À la fin, nous espérons que vous arrêterez d'attendre des PDF qu'ils se comportent comme des CSV déguisés en imperméable.
La mauvaise ascendance
Le PDF n'a pas grandi en voulant être une API. Il a grandi en voulant être un pilote d'imprimante qui n'aurait jamais à rencontrer votre imprimante. Le modèle PDF est essentiellement : une séquence d'instructions de dessin.
Cela signifie « dessiner du texte ici, dans cette police, à cette taille, puis décaler un peu, puis dessiner plus de texte ». Cela ne signifie pas « ceci est un tableau avec 5 colonnes et un en-tête ». Il n'y a pas de concept natif de lignes, de colonnes ou même de mots. Il n'y a que des glyphes placés à des coordonnées.
Si vous gardez cela à l'esprit, tout ce qui suit a… eh bien, pas du sens, mais semble au moins moins malveillant.
Le tour du propriétaire d'un PDF en 30 secondes
À l'intérieur d'un PDF, vous trouverez :
Objets : des blobs numérotés (dictionnaires, tableaux, flux) qui définissent les pages, les polices, les images, etc.
Content streams : des flux d'octets compressés contenant les commandes de dessin pour chaque page.
Opérateurs de texte : des choses comme
BT(begin text),Tj/TJ(show text),Td/Tm(move the text matrix), etc.Cross‑reference (xref) :
L'annuaire qui indique à un lecteur où chaque objet réside dans le fichier.
Vous pouvez ouvrir un PDF simple dans un éditeur de texte et plisser les yeux. Certaines parties seront lisibles ; d'autres ressembleront à de la neige (statique) car ce sont des flux compressés. C'est normal.
Un flux de contenu jouet
Voici un extrait considérablement simplifié (et aseptisé) tel que vous pourriez le voir après avoir décompressé le flux de contenu d'une page :

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
Deux choses importantes viennent de se produire :
TjetTJdessinent des glyphes, pas des « caractères dans un mot ». Parfois, la police encode les glyphes de manière étrange (encodages personnalisés, sous-ensembles). Vous ne pouvez pas supposer que les octets correspondent à l'Unicode.TJprend un tableau de chaînes et de nombres. Les nombres ajustent l'espacement (crénage). Les gens l'utilisent pour rendre les chiffres jolis. Votre extracteur doit maintenant calculer les positions visuelles pour reconstruire où « 1 2 3 4 » atterrissent réellement.
Pourquoi votre extracteur se fait manipuler (gaslit)
Passons en revue trois pièges courants qui transforment un « PDF facile » en « week-end gâché ».
1) Le texte n'est pas du texte
Si un fichier intègre une police en sous-ensemble (par ex. /F1+ABCDEE+Inter), la lettre visible A pourrait en fait être l'ID de glyphe 37 que cette police mappe à A, mais le glyphe 37 d'un autre PDF pourrait mapper à Ω. Si la police oublie d'inclure une carte /ToUnicode (ou ment), l'extraction brute donne des déchets. L'OCR ne vous sauvera pas si le texte est déjà composé de glyphes vectoriels.
Symptôme : Vous voyez des chaînes Tj comme \x12\x7F\x03 et votre outil renvoie joyeusement .
Solution (bricolage) : Heuristiques + décodage de police + prier pour que la carte /ToUnicode existe. Sinon, vous êtes au pays de la « vision par ordinateur ».
2) Les mots sont une illusion
Il n'y a pas d'espaces à moins qu'un glyphe ne soit littéralement une espace. De nombreux PDF dessinent « CHAMPAGNE » comme neuf glyphes indépendants avec des écarts arbitraires. Que quelque chose soit un mot ou deux colonnes coïncidant l'une près de l'autre est à vous de le déduire en regroupant les coordonnées.
Symptôme : Votre « division sur les espaces » renvoie ['CH', 'AMP', 'AGNE'] ou pire, fusionne deux colonnes en un mot monstrueux.
Solution (bricolage) : Reconstruire l'ordre de lecture en utilisant la matrice de texte et des seuils de tolérance pour les écarts X/Y. Attendez-vous à devoir régler cela par document.
3) Les mises en page sont des flocons de neige uniques
Le « Montant » de la Banque A peut être aligné à droite à x=480 ; la Banque B utilise un tableau ; la Banque C rend chaque chiffre individuellement avec TJ pour aligner les décimales. La seule chose cohérente à propos de « Description / Date / Montant » est que c'est incohérent.
Symptôme : Votre analyseur basé sur des règles fonctionne sur 9 PDF puis laisse tomber silencieusement les décimales sur le 10ème.
Solution (bricolage) : Séparer le rendu de la sémantique. Construire un modèle visuel (boîtes, lignes, suites de texte) puis un mappage appris du visuel → champs. Ou maintenir des modèles par émetteur si votre plan à long terme inclut la tristesse.
Une petite expérience : créer, puis extraire
D'abord, nous allons générer un relevé bancaire jouet. N'importe quelle bibliothèque PDF fonctionne ; la clé est : nous allons rendre ce que les humains voient, pas des « données structurées ».
# 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")
Maintenant, essayons d'extraire. Nous allons démontrer trois stratégies en pseudocode d'abord (voir l'Annexe pour les scripts exécutables) : « marteau à chaînes », « parcours de texte naïf » et « conscient de la mise en page ».
# 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 : La méthode 1 n'extrait presque rien. La méthode 2 renvoie du texte brouillé pour cette ligne /ToUnicode manquante et fusionne les colonnes. La méthode 3 est… enfin utilisable, jusqu'à ce que vous essayiez le modèle d'une deuxième banque.
Le boss final : le relevé bancaire
Imaginons qu'un client télécharge trois relevés bancaires différents pour le KYC. Ils semblent identiques pour une personne :
- Date, Description, Montant.
- Les montants négatifs ont un signe moins ; les décimales utilisent une virgule parce que… l'Europe.
Sous le capot, ce sont des monstres complètement différents :
- Émetteur A utilise ToUnicode et un simple
Tj. Mode facile. - Émetteur B dessine les chiffres avec
TJcomme[ (1) -30 (2) -30 (3) -30 (,) -20 (4) ]pour que les décimales s'alignent. Aligné à droite à x≈480. - Émetteur C a intégré une police Type 3 avec des glyphes personnalisés. Pas de ToUnicode. La virgule est en fait l'ID de glyphe 17, que votre bibliothèque décode comme
\x11.
Un pipeline doit :
- Décoder les polices quand c'est possible ; se rabattre sur l'OCR uniquement si nécessaire.
- Reconstruire l'ordre de lecture par la géométrie (pas l'ordre du texte).
- Normaliser les paramètres régionaux (
,vs.; espaces insécables ; parenthèses pour les négatifs). - Détecter et corriger la « salade de chiffres » provenant de
TJen calculant les positions réelles des glyphes.
Voici le genre de post-traitement que vous finissez par écrire (pseudocode) :
# (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}
Et non, ce n'est pas une seule fonction. C'est un jardin d'heuristiques, chacune adoptée après un rapport de bug.
« Mais le mien fonctionne sur 95 % des fichiers ! »
Pareil. Le PDF médian est correct. La queue de distribution (long tail), c'est là où vivent les équipes de conformité. La longue traîne comprend :
- Scans (bitmaps) entrelacés avec du texte vectoriel.
- PDF linéarisés où les objets sont mélangés pour le streaming.
- Xrefs étranges et flux d'objets compressés.
- PDF balisés qui prétendent avoir une structure (parfois utile ! parfois des mensonges !).
Vous ne les remarquez pas jusqu'à ce qu'un client critique utilise exactement cet export provenant d'exactement ce système bancaire central (core banking).
Pensée finale → et comment nous faisons cela chez Holofin
Les PDF sont merveilleux pour ce pour quoi ils ont été conçus : montrer de manière fiable la même page aux humains. Si vous avez besoin de données, demandez des données, CSV, JSON, XLSX. Et quand la réalité dit « le régulateur veut des PDF », vous avez besoin d'un pipeline qui traite les PDF comme les minuscules programmes graphiques qu'ils sont.
Chez Holofin, c'est littéralement notre description de poste : transformer des PDF chaotiques du monde réel en données structurées, validées et prêtes pour la production, des exports propres aux scans tachés de café des années 1990.
Nos principes
- La structure avant la sémantique. Nous reconstruisons d'abord la géométrie (glyphes → mots → lignes → blocs → tableaux) et seulement ensuite nous attribuons du sens. Cela évite les bugs du type « bons chiffres, mauvais en-tête ».
- Des ancres partout. Chaque valeur porte le numéro de page, la boîte englobante et la lignée de la pile d'en-têtes afin que vous puissiez cliquer pour revenir à la source lors de la révision/débogage.
- Sorties déterministes. Nous livrons des valeurs cohérentes et auditables dérivées du contenu du document ; les unités et les devises sont préservées telles que fournies par la source.
- Contraintes > vibes. Les totaux doivent correspondre à la somme des sous-totaux ; les bilans doivent s'équilibrer ; les dates doivent être plausibles. Lorsque les règles échouent, nous essayons des stratégies et heuristiques alternatives.
À quoi cela ressemble sur un relevé bancaire
Rappelez-vous nos trois émetteurs (simple Tj, crénage avec TJ, polices en sous-ensemble sans ToUnicode) ? Holofin les gère par :
- Extraction de texte résiliente : Nous combinons le décodage de texte natif avec un repli OCR lorsque c'est approprié pour garantir des résultats cohérents à travers les polices, les encodages et les scans.
- Reconstruction de mise en page basée sur la géométrie : Nous reconstruisons l'ordre de lecture, les lignes et les colonnes à partir de la géométrie sur la page afin que les différences de formatage ne cassent pas l'analyse.
- Interprétation consciente du domaine : Nous attribuons une sémantique avec des validations financières (par ex., les soldes et les sous-totaux doivent se réconcilier) pour éviter des valeurs plausibles mais fausses.
- Sortie auditable et révisable : Nous renvoyons des données structurées avec une provenance pour soutenir la révision humaine et la traçabilité.
Pourquoi cela compte
En finance, une erreur d'extraction d'un pour cent ne ressemble pas à une faute de frappe, cela ressemble à un changement de valorisation. Nous concevons pour la précision et la cohérence grâce à des contrôles en couches, des réconciliations et des pistes d'audit complètes, offrant une fiabilité en laquelle les systèmes en aval et les réviseurs peuvent avoir confiance.
Ce que vous obtenez clé en main
- Précision « zero-shot » de plus de 97 % sur les documents financiers courants (et des outils pour examiner les quelques pour cent restants).
- Traitement multi-documents (par ex., une année de relevés) en un seul appel API avec une sortie consolidée et normalisée.
- Mode débogage avec vérité terrain source et pistes d'audit pour chaque valeur.
- Prêt pour l'entreprise API REST et interface utilisateur Web, conçues pour l'échelle et la sécurité, avec une rétention des données configurable (compatible RGPD par défaut).
Si cela résonne avec les cicatrices de votre propre analyseur PDF, parlons-en.
Articles connexes
Quand les documents contre-attaquent
Page 1 : Résumé du compte, deux colonnes. Page 15 : Même compte, trois colonnes, noms d'en-tête différents. Page 47 : Un scan avec une tache de café. Page 89 : La page des totaux, qui fait référence à des transactions que vous avez extraites il y a 70 pages.

La piste d'audit invisible
Un auditeur ouvre votre fichier d'export, trouve un solde de clôture de 47 500 € et sort le PDF source. Page 3, coin inférieur droit : 47 000 €. Un chiffre différent. « D'où vient la différence ? Qui l'a modifié ? »

HoloRecall : Montrer plutôt que raconter
Il y a un moment dans chaque projet de classification où vous voyez le modèle se tromper avec assurance. Pas un cas difficile. Pas un cas limite ambigu. Quelque chose qu'un humain résoudrait en une demi-seconde sans réfléchir.