Récupérer le texte d’un PDF
Obtenir le texte d’un document PDF, page par page, dans l’ordre où il se lit.
Pas besoin d’IA
GratuitInstantanéVos données ne sortent pas
Le verdict
N0, parce que le texte est dans le fichier et qu’aucune reconnaissance n’a lieu : ce que rend la bibliothèque est ce que le document contient, au caractère près. Le seul travail à faire est l’ordre de lecture, et il se fait sans modèle : une page à deux colonnes, dessinée ligne par ligne comme l’exportent beaucoup de traitements de texte, donne « La boulangerie Martin fête Clémentine Martin a repris » quand on la lit naïvement, et la recherche des gouttières la remet dans l’ordre. Ce que la fiche refuse de faire est aussi important : un mot coupé en fin de ligne reste coupé, parce que le recoller ferait de « Boulogne- » et « Billancourt » un seul mot une fois sur deux.
Le texte du fichier, lu colonne par colonne
Pas besoin d’IA
Règle et algorithme classiqueN0GratuitInstantané
Python
"""
Sortir le texte d'un PDF, dans l'ordre où il se lit.
Niveau N0. Un PDF ne range pas un texte : il range des instructions pour
dessiner des glyphes à des coordonnées. Les retransformer en caractères est
ce que font `pypdf` et `pdftotext`, et `pdf.js` en JavaScript. Cet extrait
n'appelle aucun des trois : il passe par `pdfplumber`, qui est celui qui rend
aussi la position de chaque mot, et c'est la position que travaille la suite
de ce fichier. Aucun d'eux n'a besoin d'un modèle : les caractères sont dans
le fichier, exactement, et aucune reconnaissance n'a lieu.
Ce qu'ils ne font pas, c'est décider dans quel ordre ces caractères se
lisent. Un traitement de texte qui exporte deux colonnes les dessine souvent
ligne par ligne, la gauche puis la droite, si bien que la lecture naïve donne
« La boulangerie Martin fête Clémentine Martin a repris ». Les colonnes sont
donc cherchées ici, par les gouttières — les bandes de la page où aucun mot
ne se trouve — et chacune est lue de haut en bas avant que la suivante
commence.
Une page à plus de deux bandes n'est pas réordonnée du tout. Deux colonnes,
c'est ce qu'exporte un traitement de texte ; trois ou plus, sur une page que
personne ici n'a composée, c'est un tableau, un formulaire ou un tarif, et
les gouttières d'un tableau sont aussi larges que celles d'un texte. Lire ses
bandes de haut en bas rendrait toutes les références, puis toutes les
désignations, puis tous les prix, et le lien entre MC-4501 et 19,90 aurait
disparu. Ces pages sont lues dans l'ordre du dessin, et `reason` le dit sur
la page concernée. La suite est la fiche `extract-tables-from-a-pdf`, qui lit
la grille au lieu de la prose.
Le compte de bandes est tout ce dont cette règle dispose, et c'est ce qui est
portable : le nombre de mots d'une ligne dépend de la façon dont l'extracteur
a découpé les suites de caractères, et `pdfplumber` et `pdf.js` ne les
découpent pas pareil. Un tableau à exactement deux colonnes — un libellé à
gauche, un montant à droite — est donc toujours lu comme deux colonnes, et
toujours transposé. C'est la première ligne du point de rupture, pas une note
de bas de page.
Une chose est laissée telle quelle, délibérément : un mot coupé en fin de
ligne reste coupé. Recoller « généra- » et « tions » tient en une ligne de
code, et recollerait aussi « Boulogne- » et « Billancourt », qui est une
autre ville. L'appelant connaît son corpus ; cette fonction ne devine pas, et
elle compte ces lignes dans `hyphenated_lines` pour qu'il décide du sien.
"""
from __future__ import annotations
import io
# Two words are on the same line if their baselines are within this many points.
SAME_LINE = 3.0
# A band of the page this wide with no word in it is a gutter between columns.
# An ordinary word space is under five points; a column gutter is twenty to
# forty. Twenty-five sits between the two. It is the default of `min_gutter`,
# which the caller can move: a page laid out in a tighter grid needs a smaller
# one, and a page with wide word spacing a larger.
MIN_GUTTER = 25.0
# Above this many bands, the page is not a document in columns: it is a table,
# a form or a price list, and reading its bands top to bottom would transpose
# it. Two is the layout a word processor exports; three or more, on a page the
# caller did not typeset, is a grid.
MAX_COLUMNS = 2
def read_text(pdf_bytes, *, pages=None, min_gutter: float = MIN_GUTTER) -> dict:
"""
The text of the document, page by page, read column by column.
`columns` in each page's report is how many bands the code found, which is
what tells a caller that the page was not a simple column of prose. It says
what was found, not what was done: when those bands are a grid rather than
columns, the page is read in page order and its `reason` says so.
Reordering a table transposes it, and a transposed table is a wrong text,
not an incomplete one.
`hyphenated_lines` counts the lines that end on a hyphen, because this
function never glues them back and the caller may want to.
"""
try:
import pdfplumber
document = pdfplumber.open(io.BytesIO(pdf_bytes))
except Exception as error: # noqa: BLE001 - a broken file is an answer too
return {"pages": [], "reason": f"this file could not be opened as a PDF: {error}"}
read = []
with document:
for number, page in enumerate(document.pages, start=1):
if pages is not None and number not in pages:
continue
words = [{"text": w["text"], "x": w["x0"], "end": w["x1"], "y": -w["top"]}
for w in page.extract_words()]
bands = find_columns(words, min_gutter=min_gutter)
reason = None
reading = bands
if len(bands) > MAX_COLUMNS:
# The bands are kept in `columns`: the caller is told what was
# found as well as what was done with it.
reading = [(bands[0][0], bands[-1][1])]
reason = "this page looks like a table: read in page order"
blocks = [_column_text(words, band) for band in reading]
text = "\n\n".join(block for block in blocks if block)
read.append({"page": number, "columns": len(bands), "text": text,
"hyphenated_lines": sum(1 for line in text.split("\n")
if line.endswith("-")),
"reason": reason})
return {"pages": read, "reason": None}
def find_columns(words, *, min_gutter: float = MIN_GUTTER) -> list:
"""
The horizontal bands the page's text sits in, left to right.
The page is swept one point at a time; a run of points no word overlaps,
wider than a gutter, separates two columns.
"""
if not words:
return []
left = int(min(word["x"] for word in words))
right = int(max(word["end"] for word in words)) + 1
busy = bytearray(right - left)
for word in words:
for position in range(int(word["x"]) - left, int(word["end"]) - left):
busy[position] = 1
bands, start, gap = [], 0, 0
for position, occupied in enumerate(busy):
if occupied:
if gap >= min_gutter and position - gap > start:
bands.append((left + start, left + position - gap))
start = position - gap
gap = 0
else:
gap += 1
bands.append((left + start, right))
return bands
def _lines_of(words, band) -> list:
"""The words of one band, grouped into lines, top to bottom."""
middle = [w for w in words if band[0] <= (w["x"] + w["end"]) / 2 < band[1]]
lines = []
for word in sorted(middle, key=lambda w: (-w["y"], w["x"])):
if lines and abs(lines[-1][0]["y"] - word["y"]) <= SAME_LINE:
lines[-1].append(word)
else:
lines.append([word])
return lines
def _column_text(words, band) -> str:
"""The words of one band, read top to bottom, left to right."""
return "\n".join(" ".join(word["text"] for word in line)
for line in _lines_of(words, band))JavaScript
/**
* Sortir le texte d'un PDF, dans l'ordre où il se lit.
*
* Niveau N0. Un PDF ne range pas un texte : il range des instructions pour
* dessiner des glyphes à des coordonnées. Les retransformer en caractères est
* ce que fait `pdf.js` ici, et ce que font `pypdf` ou `pdftotext` en Python —
* l'extrait Python de cette fiche n'appelle ni l'un ni l'autre, il passe par
* `pdfplumber`, qui est celui qui rend aussi la position de chaque mot. Aucun
* d'eux n'a besoin d'un modèle : les caractères sont dans le fichier,
* exactement, et aucune reconnaissance n'a lieu.
*
* Ce qu'ils ne font pas, c'est décider dans quel ordre ces caractères se
* lisent. Un traitement de texte qui exporte deux colonnes les dessine souvent
* ligne par ligne, la gauche puis la droite, si bien que la lecture naïve donne
* « La boulangerie Martin fête Clémentine Martin a repris ». Les colonnes sont
* donc cherchées ici, par les gouttières — les bandes de la page où aucun mot
* ne se trouve — et chacune est lue de haut en bas avant que la suivante
* commence.
*
* Une page à plus de deux bandes n'est pas réordonnée du tout. Deux colonnes,
* c'est ce qu'exporte un traitement de texte ; trois ou plus, sur une page que
* personne ici n'a composée, c'est un tableau, un formulaire ou un tarif, et
* les gouttières d'un tableau sont aussi larges que celles d'un texte. Lire ses
* bandes de haut en bas rendrait toutes les références, puis toutes les
* désignations, puis tous les prix, et le lien entre MC-4501 et 19,90 aurait
* disparu. Ces pages sont lues dans l'ordre du dessin, et `reason` le dit sur
* la page concernée. La suite est la fiche `extract-tables-from-a-pdf`, qui lit
* la grille au lieu de la prose.
*
* Le compte de bandes est tout ce dont cette règle dispose, et c'est ce qui est
* portable : le nombre de mots d'une ligne dépend de la façon dont l'extracteur
* a découpé les suites de caractères, et `pdfplumber` et `pdf.js` ne les
* découpent pas pareil. Un tableau à exactement deux colonnes — un libellé à
* gauche, un montant à droite — est donc toujours lu comme deux colonnes, et
* toujours transposé. C'est la première ligne du point de rupture, pas une note
* de bas de page.
*
* Une chose est laissée telle quelle, délibérément : un mot coupé en fin de
* ligne reste coupé. Recoller « généra- » et « tions » tient en une ligne de
* code, et recollerait aussi « Boulogne- » et « Billancourt », qui est une
* autre ville. L'appelant connaît son corpus ; cette fonction ne devine pas, et
* elle compte ces lignes dans `hyphenated_lines` pour qu'il décide du sien.
*/
// Two words are on the same line if their baselines are within this many points.
export const SAME_LINE = 3.0;
// A band of the page this wide with no word in it is a gutter between columns.
// An ordinary word space is under five points; a column gutter is twenty to
// forty. Twenty-five sits between the two. It is the default of `minGutter`,
// which the caller can move: a page laid out in a tighter grid needs a smaller
// one, and a page with wide word spacing a larger.
export const MIN_GUTTER = 25.0;
// Above this many bands, the page is not a document in columns: it is a table,
// a form or a price list, and reading its bands top to bottom would transpose
// it. Two is the layout a word processor exports; three or more, on a page the
// caller did not typeset, is a grid.
export const MAX_COLUMNS = 2;
/**
* The text of the document, page by page, read column by column.
*
* `columns` in each page's report is how many bands the code found, which is
* what tells a caller that the page was not a simple column of prose. It says
* what was found, not what was done: when those bands are a grid rather than
* columns, the page is read in page order and its `reason` says so. Reordering
* a table transposes it, and a transposed table is a wrong text, not an
* incomplete one.
*
* `hyphenated_lines` counts the lines that end on a hyphen, because this
* function never glues them back and the caller may want to.
*
* @param {Uint8Array} pdfBytes
* @param {{pages?: number[], load?: object, minGutter?: number}} [options]
*/
export async function readText(pdfBytes, { pages, load, minGutter = MIN_GUTTER } = {}) {
const pdfjs = load ?? (await import('pdfjs-dist/legacy/build/pdf.mjs'));
const read = [];
try {
const document = await pdfjs.getDocument({
data: new Uint8Array(pdfBytes), isEvalSupported: false,
}).promise;
for (let number = 1; number <= document.numPages; number += 1) {
if (pages && !pages.includes(number)) continue;
// eslint-disable-next-line no-await-in-loop -- pdf.js hands pages out one at a time
const content = await (await document.getPage(number)).getTextContent();
const words = content.items
.filter((item) => (item.str ?? '').trim())
.map((item) => ({
text: item.str.trim(),
x: item.transform[4],
end: item.transform[4] + item.width,
y: item.transform[5],
}));
const bands = findColumns(words, { minGutter });
let reason = null;
let reading = bands;
if (bands.length > MAX_COLUMNS) {
// The bands are kept in `columns`: the caller is told what was found
// as well as what was done with it.
reading = [[bands[0][0], bands.at(-1)[1]]];
reason = 'this page looks like a table: read in page order';
}
const blocks = reading.map((band) => columnText(words, band)).filter(Boolean);
const text = blocks.join('\n\n');
read.push({
page: number,
columns: bands.length,
text,
hyphenated_lines: text.split('\n').filter((line) => line.endsWith('-')).length,
reason,
});
}
} catch (error) {
return { pages: [], reason: `this file could not be opened as a PDF: ${error.message}` };
}
return { pages: read, reason: null };
}
/**
* The horizontal bands the page's text sits in, left to right.
*
* The page is swept one point at a time; a run of points no word overlaps,
* wider than a gutter, separates two columns.
*/
export function findColumns(words, { minGutter = MIN_GUTTER } = {}) {
if (words.length === 0) return [];
const left = Math.floor(Math.min(...words.map((word) => word.x)));
const right = Math.floor(Math.max(...words.map((word) => word.end))) + 1;
const busy = new Uint8Array(right - left);
for (const word of words) {
for (let p = Math.floor(word.x) - left; p < Math.floor(word.end) - left; p += 1) busy[p] = 1;
}
const bands = [];
let start = 0;
let gap = 0;
for (let position = 0; position < busy.length; position += 1) {
if (busy[position]) {
if (gap >= minGutter && position - gap > start) {
bands.push([left + start, left + position - gap]);
start = position - gap;
}
gap = 0;
} else {
gap += 1;
}
}
bands.push([left + start, right]);
return bands;
}
/** The words of one band, grouped into lines, top to bottom. */
function linesOf(words, band) {
const middle = words.filter((w) => (w.x + w.end) / 2 >= band[0] && (w.x + w.end) / 2 < band[1]);
const lines = [];
for (const word of middle.sort((a, b) => b.y - a.y || a.x - b.x)) {
const last = lines.at(-1);
if (last && Math.abs(last[0].y - word.y) <= SAME_LINE) last.push(word);
else lines.push([word]);
}
return lines;
}
/** The words of one band, read top to bottom, left to right. */
function columnText(words, band) {
return linesOf(words, band).map((line) => line.map((word) => word.text).join(' ')).join('\n');
}Essayer
Ce code ne peut pas tourner dans un navigateur. Les sorties ci-dessous sont calculées à la construction du site, en exécutant le vrai code.
Cinq documents fabriqués pour cette page. Les sorties sont celles de l’extrait, exécutées à la construction du site : aucun texte n’est écrit à la main.
Un contrat d’une page, une colonne
Donné
Le texte sort dans l’ordre où il se lit.
Ce qu’il renvoie
Contrat de prestation de services Entre la société Exemple SAS, au capital social de mille euros, dont le siège est à Boulogne- Billancourt, et le client désigné ci-après.
Ce qu’il en conclut
1 page(s), 1 colonne(s) sur la première
Le texte sort dans l’ordre où il se lit.
Une lettre d’information sur deux colonnes
Donné
Les gouttières séparent les colonnes, et chacune est lue entièrement avant la suivante.
Ce qu’il renvoie
La boulangerie Martin fête ses cent ans cette année. Le four à bois, classé, tourne encore tous les matins. Clémentine Martin a repris le commerce en 2019. La farine vient du même moulin depuis quatre générations.
Ce qu’il en conclut
1 page(s), 2 colonne(s) sur la première
Les gouttières séparent les colonnes, et chacune est lue entièrement avant la suivante.
Deux pages de mises en page différentes
Donné
Le nombre de colonnes est rendu page par page.
Ce qu’il renvoie
La boulangerie Martin fête ses cent ans cette année. Le four à bois, classé, tourne encore tous les matins. Clémentine Martin a repris le commerce en 2019. La farine vient du même moulin depuis quatre générations. — — — Contrat de prestation de services Entre la société Exemple SAS, au capital social de mille euros, dont le siège est à Boulogne- Billancourt, et le client désigné ci-après.
Ce qu’il en conclut
2 page(s), 2 colonne(s) sur la première
Le nombre de colonnes est rendu page par page.
Une page scannée
Donné
Aucun caractère à lire : zéro colonne, texte vide, et rien d’inventé.
Ce qu’il renvoie
(vide)
Ce qu’il en conclut
1 page(s), 0 colonne(s) sur la première
Aucun caractère à lire : zéro colonne, texte vide, et rien d’inventé.
Un texte justifié avec ses césures
Donné
Deux mots coupés en fin de ligne.
Ce qu’il renvoie
La boulangerie Martin, installée à Boulogne- Billancourt, a vu passer quatre généra- tions de clients depuis 1926.
Ce qu’il en conclut
1 page(s), 1 colonne(s) sur la première
Deux mots coupés en fin de ligne.
Là, ça casse« quatre généra- » et « tions de clients » restent deux morceaux : le texte extrait est le texte imprimé, pas le texte écrit. Les recoller tient en une ligne de code, et cette ligne ferait aussi de « Boulogne- » et « Billancourt » un seul mot — qui est une autre ville. L’extrait ne devine pas ; c’est à l’appelant, qui connaît son corpus, de décider.
Ce que ça implique
- Vos données ne sortent pas
- Résultat toujours identique
- Se teste unitairement, cas par cas
- Dépend d’une bibliothèque
- Empreinte faible
Ce que cela ajoute à votre périmètre réglementaire
- Aucun périmètre spécifique ajouté : le document est lu dans votre processus et n’est transmis à personne
- Un contrat, une facture ou un courrier porte des données personnelles : ce niveau est celui qui les garde chez vous
Point de rupture
Un tableau à exactement deux colonnes est lu comme deux colonnes, donc transposé : les libellés d’abord, les montants ensuite. Au-delà de deux bandes, la page est lue dans l’ordre imprimé et `reason` le dit — c’est le témoin, dans le même test, sur une facture à quatre colonnes.
Quand passer au niveau suivant
Il n’y a pas de niveau au-dessus. Le jour où le rapport rend zéro colonne et un texte vide, le PDF ne porte pas de texte du tout : c’est un scan, et la question devient la reconnaissance de caractères.
Les quatre options
- RecommandéN0Le texte du fichier, lu colonne par colonneGratuit · Instantané
- N1Niveau sans objet ici
- N2Niveau sans objet ici
- N3Niveau sans objet ici
Les autres options
N1Niveau sans objet ici
Niveau sans objet ici
Modèle classique légerN1
Il n’y a rien à apprendre du contenu : les caractères sont écrits dans le fichier, et aucun modèle ne les rendra plus exacts. La seule décision de cette fiche — où sont les colonnes — se prend sur une projection des positions des mots, c’est-à-dire sur une bande vide de vingt-cinq points ; un classifieur entraîné sur des mises en page annotées donnerait la même réponse avec un jeu de données à constituer.
N2Niveau sans objet ici
Niveau sans objet ici
Petit modèle spécialisé auto-hébergéN2
Les modèles d’analyse de mise en page existent, et ils servent sur des pages dont l’ordre de lecture est réellement difficile : un journal, un formulaire, une affiche. Ils demandent de rendre la page en image, ce qui coûte plus cher que tout ce que fait cette fiche, et ils ne changent rien au texte lui-même, qui est déjà exact. Le gain se limiterait aux mises en page que la recherche de gouttières manque, et le prix est un modèle résident sur chaque machine qui lit un PDF.
N3Niveau sans objet ici
Niveau sans objet ici
API de LLM généralisteN3
Un modèle généraliste rend un texte qui ressemble à celui du document : sur un contrat ou une facture, la différence entre « ressemble » et « est » porte un nom. Le fichier contient le texte exact, et la lecture se vérifie — chaque caractère rendu se retrouve dans le flux de contenu. S’y ajoutent une sortie de données par document et une facture par page, pour un travail que la même machine fait en quelques millisecondes.
Pour aller plus loin
- pypdf — la bibliothèque Python, et ses modes d’extraction de texte
- pdftotext — l’outil en ligne de commande de Poppler
- pdf.js — la bibliothèque de Mozilla, et son contenu textuel
- Savoir si un PDF a besoin d’OCR — la fiche qui traite l’étape d’avant
Source des chiffres
- pypdf, documentation de l’extraction de texte (consultée) : ce que rend la bibliothèque, et le mode « layout » qui conserve les positions au prix d’un remplissage par des espaces
- pdfplumber sur son dépôt (consulté) : version 0.11.10, publiée le 15 juin 2026, licence MIT ; c’est lui qui fournit ici les mots et leurs coordonnées
- pdf.js, API getTextContent (consultée) : chaque élément de texte porte sa matrice de transformation, d’où sortent l’abscisse et la largeur employées ici
Métadonnées
Information générale, tenue à jour au mieux. Ce n’est pas un avis juridique. Faites valider votre situation par un professionnel.