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

snippets/convert-a-pdf-to-plain-text/n0.pyTesté tel quel — Cet extrait s’exécute avec ses vraies dépendances, et son test tourne à chaque construction du site.
"""
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

snippets/convert-a-pdf-to-plain-text/n0.jsTesté tel quel — Cet extrait s’exécute avec ses vraies dépendances, et son test tourne à chaque construction du site.
/**
 * 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.

  1. 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.

  2. 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.

  3. 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.

  4. 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é.

  5. 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

  1. RecommandéN0Le texte du fichier, lu colonne par colonneGratuit · Instantané
  2. N1Niveau sans objet ici
  3. N2Niveau sans objet ici
  4. 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

Source des chiffres

Métadonnées