Lire le texte d’une page scannée

Récupérer le texte d’un document arrivé en PDF ou en image, pour l’indexer, l’archiver ou le relire.

De l’IA, mais un petit modèle chez vous

modéréUne secondeVos données restent chez vous

Le verdict

N2, parce que sur une page réellement scannée il ne reste que les pixels, et que c’est le seul niveau qui les lise chez vous, sans clé ni quota. N0 passe toujours en premier : il coûte un appel de fonction, il règle tout le besoin quand le document portait déjà son texte, et quand il ne le porte pas, il le dit au lieu de rendre une page vide — c’est le réflexe, pas la solution générale. N3 reste pour ce que Tesseract dit lui-même mal lire, l’écriture manuscrite et les tableaux, sans que rien ici ne prouve que le modèle les lise mieux, et il le paie de la seule chose qui compte : sa transcription peut avoir été écrite plutôt que lue, et rien dans le code ne peut s’en apercevoir.

Moteur de reconnaissance optique auto-hébergé, avec seuil de relecture

De l’IA, mais un petit modèle chez vous

Petit modèle spécialisé auto-hébergéN2modéréUne seconde

Python

snippets/read-text-from-a-scanned-page/n2.pyTesté, service simulé — Cet extrait s’exécute à chaque construction du site, mais son test remplace le service externe par un double local : la requête envoyée, la réponse décodée et les cas d’erreur sont vérifiés, la réponse du vrai fournisseur ne l’est pas.
"""
Lire une page scannée avec un moteur de reconnaissance optique auto-hébergé.

Niveau N2. Quand N0 a répondu « pas de couche de texte », quelque chose doit
regarder les pixels. Un moteur comme Tesseract fait exactement cela, sur votre
machine : la page ne la quitte pas, et il n'y a ni clé ni quota.

Ce que vous tenez à ce niveau n'est pas le moteur, c'est tout ce qui
l'entoure : la langue que vous lui annoncez, la confiance sous laquelle une
page part en relecture humaine, le nettoyage d'un texte qui revient coupé en
lignes, et la réponse à « que fait le code quand le moteur ne dit rien
d'utilisable ».
"""

from __future__ import annotations

import functools
import re

# The language data the engine loads, named by its Tesseract code: `fra` for
# French documents.
LANGUAGE = "fra"

# The engine reports how sure it is of each word. Below this, the page is
# still returned, but flagged: the text is a draft, not a reading.
DEFAULT_MIN_CONFIDENCE = 0.70


class OCRUnavailable(Exception):
    """The engine failed, or answered something no caller can act on."""


@functools.cache
def default_engine():
    """The real engine, built on first use and kept, as in JavaScript."""
    return TesseractOCR()


class TesseractOCR:
    """The real engine, built once. pytesseract starts the tesseract binary for every call."""

    def __init__(self, language: str = LANGUAGE) -> None:
        import pytesseract  # wraps the tesseract binary installed on the host

        self._pytesseract = pytesseract
        self.language = language

    def read(self, image_path: str) -> dict:
        """The text of one image, line by line, and how sure the engine is of it."""
        from PIL import Image

        data = self._pytesseract.image_to_data(
            Image.open(image_path),
            lang=self.language,
            output_type=self._pytesseract.Output.DICT,
        )
        # One row per layout box: a row of level 4 opens a line, the rows of
        # level 5 that follow are its words, each scored out of a hundred.
        lines, scores = [], []
        for level, word, score in zip(data["level"], data["text"], data["conf"]):
            if level == 4:
                lines.append([])
            elif level == 5 and lines and word.strip() and float(score) >= 0:
                lines[-1].append(word)
                scores.append(float(score))
        return {
            "text": "\n".join(" ".join(words) for words in lines),
            "confidence": sum(scores) / (100 * len(scores)) if scores else 0.0,
        }


def read_page(image_path, engine=None, *, min_confidence: float = DEFAULT_MIN_CONFIDENCE, attempts: int = 2) -> dict:
    """
    Read one page image, and say whether a human should check the result.

    `engine` is injected so this can be tested without installing the binary
    or the language data. In production it defaults to the real engine above.
    """
    engine = engine or default_engine()
    reading = _read(engine, image_path, attempts)
    if not isinstance(reading, dict) or not isinstance(reading.get("text"), str):
        raise OCRUnavailable("the engine owed a reading, and did not give one")

    text = clean(reading["text"])
    confidence = _confidence(reading.get("confidence"))
    # A doubtful page is not thrown away: it goes to a human with the text and
    # the score that earned the doubt. Throwing it away would cost the reading
    # that was, most of the time, almost right.
    return {"text": text, "confidence": confidence, "review": not text or confidence < min_confidence}


def _read(engine, image_path, attempts: int) -> object:
    """One page per call, and a failed call is retried, not swallowed."""
    last_error: Exception | None = None
    for _ in range(attempts):
        try:
            return engine.read(image_path)
        except Exception as error:  # noqa: BLE001 - any engine failure is retried
            last_error = error
    raise OCRUnavailable(str(last_error))


def clean(text: str) -> str:
    """Whitespace, and the hyphen a line break leaves inside a word."""
    lines = [re.sub(r"[ \t\xa0]+", " ", line).strip() for line in text.splitlines()]
    joined = "\n".join(line for line in lines if line)
    # « exemp-\nlaire » is one word the scanner cut in two, not two words. Letters
    # only: « 2024-\n000431 » is a reference, and keeps its hyphen.
    return re.sub(r"([^\W\d_])-\n([^\W\d_])", r"\1\2", joined)


def _confidence(value) -> float:
    """A number the caller can act on, rather than whatever came back."""
    if isinstance(value, bool) or not isinstance(value, (int, float)):
        return 0.0
    return float(value) if 0.0 <= value <= 1.0 else 0.0

JavaScript

snippets/read-text-from-a-scanned-page/n2.jsTesté, service simulé — Cet extrait s’exécute à chaque construction du site, mais son test remplace le service externe par un double local : la requête envoyée, la réponse décodée et les cas d’erreur sont vérifiés, la réponse du vrai fournisseur ne l’est pas.
/**
 * Lire une page scannée avec un moteur de reconnaissance optique auto-hébergé.
 *
 * Niveau N2. Quand N0 a répondu « pas de couche de texte », quelque chose doit
 * regarder les pixels. Un moteur comme Tesseract fait exactement cela, sur votre
 * machine : la page ne la quitte pas, et il n'y a ni clé ni quota.
 *
 * Ce que vous tenez à ce niveau n'est pas le moteur, c'est tout ce qui
 * l'entoure : la langue que vous lui annoncez, la confiance sous laquelle une
 * page part en relecture humaine, le nettoyage d'un texte qui revient coupé en
 * lignes, et la réponse à « que fait le code quand le moteur ne dit rien
 * d'utilisable ».
 */

// The language data the engine loads, named by its Tesseract code: `fra` for
// French documents.
export const LANGUAGE = 'fra';

// The engine reports how sure it is of each word. Below this, the page is
// still returned, but flagged: the text is a draft, not a reading.
export const DEFAULT_MIN_CONFIDENCE = 0.7;

export class OCRUnavailable extends Error {}

/** The real engine: one worker, started on first use and kept for the process. */
export class TesseractOCR {
  static async load(language = LANGUAGE) {
    // tesseract.js downloads the language data from a CDN unless `langPath`
    // points at local files.
    const { createWorker } = await import('tesseract.js');
    return new TesseractOCR(await createWorker(language));
  }

  constructor(worker) {
    this.worker = worker;
  }

  /** The text of one image, and how sure the engine is of it. */
  async read(imagePath) {
    const { data } = await this.worker.recognize(imagePath);
    // Since tesseract.js 6, words are not returned unless asked for; `confidence`
    // is the engine's mean word score, out of a hundred.
    return { text: data.text, confidence: data.confidence / 100 };
  }
}

let worker; // a promise, so two pages read at once still share one worker

function defaultEngine() {
  worker ??= TesseractOCR.load().catch((error) => {
    worker = undefined; // a failed start is not kept: the next page tries again
    throw error;
  });
  return worker;
}

/**
 * Read one page image, and say whether a human should check the result.
 *
 * `engine` is injected so this can be tested without installing the binary or
 * the language data. In production it defaults to the real engine above.
 *
 * @param {string} imagePath
 * @param {{read: Function}} [engine]
 * @param {{minConfidence?: number, attempts?: number}} [options]
 */
export async function readPage(imagePath, engine, { minConfidence = DEFAULT_MIN_CONFIDENCE, attempts = 2 } = {}) {
  const reader = engine ?? (await defaultEngine());
  const reading = await read(reader, imagePath, attempts);
  if (typeof reading?.text !== 'string') {
    throw new OCRUnavailable('the engine owed a reading, and did not give one');
  }

  const text = clean(reading.text);
  const confidence = confidenceOf(reading.confidence);
  // A doubtful page is not thrown away: it goes to a human with the text and
  // the score that earned the doubt. Throwing it away would cost the reading
  // that was, most of the time, almost right.
  return { text, confidence, review: text === '' || confidence < minConfidence };
}

/** One page per call, and a failed call is retried, not swallowed. */
async function read(engine, imagePath, attempts) {
  let lastError;
  for (let i = 0; i < attempts; i += 1) {
    try {
      return await engine.read(imagePath);
    } catch (error) {
      lastError = error;
    }
  }
  throw new OCRUnavailable(String(lastError));
}

/** Whitespace, and the hyphen a line break leaves inside a word. */
export function clean(text) {
  const lines = text
    .split('\n')
    .map((line) => line.replace(/[ \t\xa0]+/g, ' ').trim())
    .filter((line) => line !== '');
  // « exemp-\nlaire » is one word the scanner cut in two, not two words. Letters
  // only: « 2024-\n000431 » is a reference, and keeps its hyphen.
  return lines.join('\n').replace(/(\p{L})-\n(\p{L})/gu, (_, before, after) => before + after);
}

/** A number the caller can act on, rather than whatever came back. */
function confidenceOf(value) {
  return typeof value === 'number' && value >= 0 && value <= 1 ? value : 0;
}

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.

Ce que le moteur lit dans les pixels est simulé par le double local qui sert aux tests. Ce qui est calculé ici, c’est le nettoyage du texte qui revient, le seuil sous lequel la page part en relecture, et ce que le code fait d’un appel tombé.

  1. Un scan net de facture

    Donné

    scan-facture-000431.png — la facture Nord Fournitures, posée à plat

    Ce qu’il renvoie

    NORD FOURNITURES SAS N° 2024-000431 Émise le 3 avril 2024 NET A PAYER 92,40 EUR

    Confiance 0,94, au-dessus du seuil de 0,7 : la page est classée sans relecture. Un appel, sur votre machine, sans clé ni quota.

  2. Un mot coupé par la fin de ligne

    Donné

    scan-bon-livraison.png — un bon de livraison où « exem- plaire » tient sur deux lignes

    Ce qu’il renvoie

    Merci de joindre un second exemplaire de la facture au bon de livraison.

    Confiance 0,91, au-dessus du seuil de 0,7 : la page est classée sans relecture. Un appel, sur votre machine, sans clé ni quota.

  3. Un fax pâle

    Donné

    fax-facture-000431.png — la même facture, reçue par télécopie

    Ce qu’il renvoie

    NORD FOURNITURES SAS N° 2024-000431 Émise le 3 avril 2024 NET A PAYER 92,40 EUR

    Confiance 0,41, sous le seuil de 0,7 : le texte est gardé et la page part en relecture humaine. Un appel, sur votre machine, sans clé ni quota.

  4. Le verso vierge d’une page

    Donné

    scan-verso-vierge.png — le dos d’une feuille, passé au scanner avec le reste

    Ce qu’il renvoie

    rien

    Le moteur se dit sûr à 0,99, et n’a pourtant rendu aucun texte : la page part en relecture, parce qu’une page blanche n’est pas une lecture.

  5. Un appel tombe

    Donné

    scan-facture-000431.png — la même page, mais le premier appel échoue

    Ce qu’il renvoie

    NORD FOURNITURES SAS N° 2024-000431 Émise le 3 avril 2024 NET A PAYER 92,40 EUR

    Le premier appel est tombé, le second a répondu. Confiance 0,94, au-dessus du seuil de 0,7 : la page est classée sans relecture. 2 appels, sur votre machine, sans clé ni quota.

  6. Un zéro lu comme un O

    Donné

    scan-facture-000431-bis.png — un scan net, glyphes bien détachés

    Ce qu’il renvoie

    N° 2O24-OOO431 NET A PAYER 92,40 EUR

    Confiance 0,96, au-dessus du seuil de 0,7 : la page est classée sans relecture. Un appel, sur votre machine, sans clé ni quota.

    Là, ça casseLa facture porte la référence 2024-000431 ; le moteur rend « 2O24-OOO431 », avec la lettre O à la place du zéro, et une confiance de 0,96. Le drapeau de relecture reste baissé, puisque le score dépasse le seuil. Un seuil relevé au-dessus de 0,96 le lèverait, mais sur toutes les lectures justes rendues au même score aussi : il ne sépare pas la lecture fausse de la bonne. Ce qui la rattrape, c’est une règle sur la forme attendue de vos références, écrite à la main : le niveau N0, de nouveau.Voir le niveau au-dessus

Ce que ça implique

  • Vos données restent chez vous
  • Résultat toujours identique
  • Se mesure sur un échantillon, pas cas par cas
  • Dépend d’une bibliothèque
  • Empreinte modérée

Ce que cela ajoute à votre périmètre réglementaire

  • Traitement des documents sur votre infrastructure ; seules les données linguistiques du moteur viennent de l’extérieur, que tesseract.js télécharge par défaut depuis un CDN
  • Les pages envoyées en relecture humaine sont vues par une personne : le périmètre des accès internes est à traiter comme tel

Point de rupture

Une erreur sûre d’elle, contre laquelle un seuil de confiance ne peut rien : « N° 2O24-OOO431 », la lettre O là où la facture imprimait un zéro, rendue avec un score haut, donc sans drapeau de relecture. Relever le seuil au-dessus de ce score le lèverait, mais sur les lectures justes notées pareil aussi — le seuil attrape une page sur laquelle le moteur a peiné, il ne distingue pas une page mal lue d’une page bien lue au même score, et c’est cet aveuglement que le test démontre, sur une lecture écrite par le double. Ce qui rattrape la faute, c’est une règle sur la forme attendue de vos références, écrite à la main : N0 de nouveau.

Quand passer au niveau suivant

Vos pages portent ce que la documentation de Tesseract dit mal lire : de l’écriture manuscrite, pour laquelle le moteur n’est pas conçu, ou des tableaux, qu’il reconnaît mal sans segmentation écrite pour eux — et vous avez déjà fait ce que cette même documentation demande d’abord, binariser, redresser et numériser à trois cents points par pouce, qui fait plus pour la qualité d’une lecture que le choix du seuil.

Les quatre options

  1. N0Demander sa couche de texte au document, par une bibliothèque PDFGratuit · Instantané
  2. N1Niveau sans objet ici
  3. RecommandéN2Moteur de reconnaissance optique auto-hébergé, avec seuil de relecturemodéré · Une seconde
  4. N3Transcription par appel à un modèle généraliste multimodalélevé · Plus d’une seconde

Les autres options

N0Demander sa couche de texte au document, par une bibliothèque PDFGratuit · Instantané

Demander sa couche de texte au document, par une bibliothèque PDF

Règle et algorithme classiqueN0GratuitInstantané

Python

snippets/read-text-from-a-scanned-page/n0.pyTesté tel quel — Cet extrait s’exécute avec ses vraies dépendances, et son test tourne à chaque construction du site.
"""
Demander au document s'il porte déjà son texte, avant de sortir un OCR.

Niveau N0. Une question, posée à une bibliothèque PDF, et une réponse dont
l'appelant peut faire quelque chose.

Un PDF produit par un logiciel de comptabilité, un traitement de texte ou un
pilote d'impression en PDF porte son texte à côté de ses instructions de
dessin, déjà exact, sans étape de reconnaissance et donc sans rien à rater. Un
scan du même document, lui, porte des pixels. Les deux se ressemblent dans une
boîte aux lettres, et rien en aval ne les distingue si personne ne pose la
question.

La question est l'intérêt de ce niveau ; l'extracteur ne l'est pas. Écrire
l'extracteur est le piège : décompresser les flux de page et décoder les
opérateurs qui montrent du texte, c'est deux cents lignes qui perdent quand
même contre la première police que le fichier renumérote pour lui-même — ce
que fait un traitement de texte à chaque export. Ce travail est fait et
publié — « pypdf » ici, « pdf.js » en JavaScript, « pdftotext » en ligne de commande — et ce qui
reste à écrire, c'est la décision.

La voici : une page qui ne porte aucun texte lisible n'est pas une page vide,
et la fonction le dit au lieu de rendre une chaîne vide qu'un appelant lirait
comme « la page est blanche ». Un non vient avec son étape suivante.
"""

from __future__ import annotations

import io

# Under this many readable characters, what was found is a stamp, a page number
# or a stray label, not a text layer. Raise it for dense documents.
MIN_CHARACTERS = 24


def read_text_layer(pdf_bytes: bytes, *, min_characters: int = MIN_CHARACTERS, reader=None) -> dict:
    """
    Say whether the document carries a text layer, and return it if it does.

    The answer is a report, not a string: `has_text_layer` is the decision the
    caller acts on, and `reason` is what to tell them when it is false.

    `reader` is injected by the tests; in production it is `pypdf.PdfReader`.
    """
    if reader is None:  # imported here so the rest of this file needs nothing
        from pypdf import PdfReader as reader

    try:
        document = reader(io.BytesIO(pdf_bytes))
        pages = [page.extract_text() or "" for page in document.pages]
    except Exception as error:  # noqa: BLE001 - a broken file is an answer too
        return _report("", 0, 0, f"this file could not be opened as a PDF: {error}")

    text = "\n".join(page for page in pages if page.strip())
    characters = sum(1 for character in text if character.isprintable() and not character.isspace())
    if characters >= min_characters:
        return _report(text, characters, len(pages), None)
    return _report(text, characters, len(pages),
                   "no text layer: this page is an image, and needs OCR")


def _report(text: str, characters: int, pages: int, reason: str | None) -> dict:
    return {
        "has_text_layer": reason is None,
        "text": text,
        "characters": characters,
        "pages": pages,
        "reason": reason,
    }

JavaScript

snippets/read-text-from-a-scanned-page/n0.jsTesté tel quel — Cet extrait s’exécute avec ses vraies dépendances, et son test tourne à chaque construction du site.
/**
 * Demander au document s'il porte déjà son texte, avant de sortir un OCR.
 *
 * Niveau N0. Une question, posée à une bibliothèque PDF, et une réponse dont
 * l'appelant peut faire quelque chose.
 *
 * Un PDF produit par un logiciel de comptabilité, un traitement de texte ou un
 * pilote d'impression en PDF porte son texte à côté de ses instructions de
 * dessin, déjà exact, sans étape de reconnaissance et donc sans rien à rater. Un
 * scan du même document, lui, porte des pixels. Les deux se ressemblent dans une
 * boîte aux lettres, et rien en aval ne les distingue si personne ne pose la
 * question.
 *
 * La question est l'intérêt de ce niveau ; l'extracteur ne l'est pas. Écrire
 * l'extracteur est le piège : décompresser les flux de page et décoder les
 * opérateurs qui montrent du texte, c'est deux cents lignes qui perdent quand
 * même contre la première police que le fichier renumérote pour lui-même — ce
 * que fait un traitement de texte à chaque export. Ce travail est fait et
 * publié — « pdf.js » ici, « pypdf » en Python, « pdftotext » en ligne de commande — et ce qui
 * reste à écrire, c'est la décision.
 *
 * La voici : une page qui ne porte aucun texte lisible n'est pas une page vide,
 * et la fonction le dit au lieu de rendre une chaîne vide qu'un appelant lirait
 * comme « la page est blanche ». Un non vient avec son étape suivante.
 */

// Under this many readable characters, what was found is a stamp, a page
// number or a stray label, not a text layer. Raise it for dense documents.
export const MIN_CHARACTERS = 24;

/**
 * Say whether the document carries a text layer, and return it if it does.
 *
 * The answer is a report, not a string: `has_text_layer` is the decision the
 * caller acts on, and `reason` is what to tell them when it is false.
 *
 * `load` is injected by the tests; in production it is pdf.js.
 */
export async function readTextLayer(pdfBytes, { minCharacters = MIN_CHARACTERS, load } = {}) {
  // Imported here, and only here: nothing else in this file needs it.
  const getDocument = load ?? (await import('pdfjs-dist/legacy/build/pdf.mjs')).getDocument;
  let text;
  let pages = 0;
  try {
    const document = await getDocument({ data: new Uint8Array(pdfBytes), isEvalSupported: false }).promise;
    pages = document.numPages;
    const read = [];
    for (let number = 1; number <= pages; number += 1) {
      // 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();
      read.push(content.items.map((item) => item.str + (item.hasEOL ? '\n' : '')).join(''));
    }
    text = read.filter((page) => page.trim()).join('\n');
  } catch (error) {
    return report('', 0, 0, `this file could not be opened as a PDF: ${error.message}`);
  }

  const characters = [...text].filter((character) => !/\s/u.test(character)).length;
  if (characters >= minCharacters) return report(text, characters, pages, null);
  return report(text, characters, pages, 'no text layer: this page is an image, and needs OCR');
}

function report(text, characters, pages, reason) {
  return { has_text_layer: reason === null, text, characters, pages, reason };
}

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 négligeable

Ce que cela ajoute à votre périmètre réglementaire

  • Aucun périmètre spécifique ajouté : le document ne quitte pas votre infrastructure
  • Une page numérisée porte souvent des données personnelles, traitées ici sur votre seule infrastructure

Point de rupture

Une page réellement scannée n’a pas de couche de texte, et aucune bibliothèque n’en inventera une : le document du test est valide, il compte une page, et cette page est une photographie en plein cadre. L’extrait rend alors non pas une chaîne vide, qui se lirait « page blanche » et ferait classer un document non lu, mais un rapport qui nomme la raison et renvoie vers un OCR — c’est tout ce que ce niveau sait faire, et il ne prétend pas savoir lire des pixels.

Quand passer au niveau suivant

Le rapport revient avec « pas de couche de texte » sur des documents qu’il faut bien lire — des scans, donc, et c’est le moment de regarder les pixels.

N1Niveau sans objet ici

Niveau sans objet ici

Modèle classique légerN1

Un OCR écrit à la main — segmentation des lignes et des caractères, puis classifieur sur chaque glyphe — refait ce que fait déjà Tesseract, un moteur libre que les distributions courantes proposent en paquet, installé par une seule commande. Sur cette fiche, N2 n’est pas une montée en puissance, c’est le point d’entrée raisonnable.

N3Transcription par appel à un modèle généraliste multimodalélevé · Plus d’une seconde

Transcription par appel à un modèle généraliste multimodal

API de LLM généralisteN3élevéPlus d’une seconde

Python

snippets/read-text-from-a-scanned-page/n3.pyTesté, service simulé — Cet extrait s’exécute à chaque construction du site, mais son test remplace le service externe par un double local : la requête envoyée, la réponse décodée et les cas d’erreur sont vérifiés, la réponse du vrai fournisseur ne l’est pas.
"""
Lire une page scannée en remettant l'image à un modèle généraliste
multimodal.

Niveau N3. La page part entière, vers un modèle qui rend une transcription.

Voyez ce que le code doit faire et que N0 n'avait pas à faire : reconnaître le
format de l'image, plafonner ce qu'il envoie, encoder les octets, réessayer
après un échec, analyser une réponse qui n'est que probablement du JSON
valide, et refuser une réponse qu'il ne peut pas employer. Cette plomberie est
le vrai coût de ce niveau, et c'est la part que les tests doivent couvrir,
parce que le modèle lui-même n'est pas testable.

Et voyez ce qu'aucune de ces tuyauteries ne sait faire : dire si la
transcription est ce que la page dit. Un modèle qui lit est aussi un modèle
qui écrit.
"""

from __future__ import annotations

import base64
import json
import re

PROMPT = (
    "Transcribe the page in the image, exactly as it is printed, keeping the\n"
    "line breaks. Answer with JSON only: an object with keys `text` and\n"
    "`unreadable`, where `text` is the transcription and `unreadable` is the\n"
    "list of fragments you could not read. Never guess at a fragment you\n"
    "cannot read: leave « ... » in the text and name it in `unreadable`."
)

# The formats the provider's vision input lists: PNG, JPEG, WEBP and GIF (not
# animated, which this check does not see). Anything else, a TIFF included, is
# refused before it is sent: convert it first.
SIGNATURES = ((rb"\x89PNG\r\n\x1a\n", "image/png"), (rb"\xff\xd8\xff", "image/jpeg"),
              (rb"RIFF.{4}WEBP", "image/webp"), (rb"GIF8[79]a", "image/gif"))

# A cap on what is encoded and uploaded; base64 makes the request a third larger
# than the file. It is not a cost control: the provider bills an image by its
# dimensions, not by its bytes.
MAX_IMAGE_BYTES = 8 * 1024 * 1024

# The provider named here is an example, not a recommendation: the reasoning
# holds for any general-purpose model API, and the client is swappable. Pass
# any object with a `complete(prompt=..., image=..., temperature=...)` method.
MODEL = "gpt-4.1-mini"  # an example id: check the parameters your model accepts


class ProviderClient:
    """The one call this snippet makes, on top of the provider's SDK."""

    def __init__(self, sdk=None, model: str = MODEL):
        if sdk is None:  # pragma: no cover - needs a key and a network
            from openai import OpenAI

            sdk = OpenAI()
        self.sdk, self.model = sdk, model

    def complete(self, *, prompt: str, image: dict, temperature: float) -> str:
        # The image travels inside the message, as a data URL.
        url = f"data:{image['media_type']};base64,{image['data']}"
        response = self.sdk.chat.completions.create(
            model=self.model,
            messages=[{"role": "user", "content": [
                {"type": "text", "text": prompt},
                {"type": "image_url", "image_url": {"url": url}},
            ]}],
            temperature=temperature,
        )
        return response.choices[0].message.content


class ReadingUnavailable(Exception):
    """The provider could not be reached, or answered something unusable."""


def read_page(image_bytes: bytes, client=None, *, attempts: int = 3, max_bytes: int = MAX_IMAGE_BYTES) -> dict:
    """
    Transcribe one page image, and say what the model admits it could not read.

    `client` is injected so this function can be tested without a network call.
    In production it defaults to a real provider client.
    """
    client = client or ProviderClient()
    media_type = _media_type(image_bytes)
    if len(image_bytes) > max_bytes:
        raise ValueError(f"image larger than {max_bytes} bytes")

    answer = _ask(client, image_bytes, media_type, attempts)
    text, unreadable = answer.get("text"), answer.get("unreadable", [])
    if not isinstance(text, str) or not isinstance(unreadable, list):
        raise ReadingUnavailable("the model answered JSON that is not a transcription")

    # Two doubts, and neither is a confidence: what the model admits it could
    # not read, and a page that came back empty. See the test file.
    review = bool(unreadable) or not text.strip()
    return {"text": text, "unreadable": [str(u) for u in unreadable], "review": review}


def _media_type(image_bytes: bytes) -> str:
    """Read the format from the bytes, rather than trusting a file extension."""
    for signature, media_type in SIGNATURES:
        if re.match(signature, image_bytes, re.S):
            return media_type
    raise ValueError("unrecognised image format")


def _ask(client, image_bytes: bytes, media_type: str, attempts: int) -> dict:
    last_error: Exception | None = None
    for _ in range(attempts):
        try:
            answer = client.complete(
                prompt=PROMPT,
                # Base64 is how an image travels in a JSON request body.
                image={"media_type": media_type, "data": base64.b64encode(image_bytes).decode("ascii")},
                # The lowest temperature: the SDK documents lower values as more
                # focused and deterministic.
                temperature=0,
            )
            if not isinstance(answer, str):  # a refusal comes back as no content
                last_error = ValueError("the model answered no text")
                continue
            parsed = json.loads(answer)
            if isinstance(parsed, dict):
                return parsed
            last_error = ValueError("the model answered something that is not an object")
        except Exception as error:  # noqa: BLE001 - any provider failure is retried
            last_error = error
    raise ReadingUnavailable(str(last_error))

JavaScript

snippets/read-text-from-a-scanned-page/n3.jsTesté, service simulé — Cet extrait s’exécute à chaque construction du site, mais son test remplace le service externe par un double local : la requête envoyée, la réponse décodée et les cas d’erreur sont vérifiés, la réponse du vrai fournisseur ne l’est pas.
/**
 * Lire une page scannée en remettant l'image à un modèle généraliste
 * multimodal.
 *
 * Niveau N3. La page part entière, vers un modèle qui rend une transcription.
 *
 * Voyez ce que le code doit faire et que N0 n'avait pas à faire : reconnaître le
 * format de l'image, plafonner ce qu'il envoie, encoder les octets, réessayer
 * après un échec, analyser une réponse qui n'est que probablement du JSON
 * valide, et refuser une réponse qu'il ne peut pas employer. Cette plomberie est
 * le vrai coût de ce niveau, et c'est la part que les tests doivent couvrir,
 * parce que le modèle lui-même n'est pas testable.
 *
 * Et voyez ce qu'aucune de ces tuyauteries ne sait faire : dire si la
 * transcription est ce que la page dit. Un modèle qui lit est aussi un modèle
 * qui écrit.
 */

export const PROMPT = [
  'Transcribe the page in the image, exactly as it is printed, keeping the',
  'line breaks. Answer with JSON only: an object with keys `text` and',
  '`unreadable`, where `text` is the transcription and `unreadable` is the',
  'list of fragments you could not read. Never guess at a fragment you',
  'cannot read: leave « ... » in the text and name it in `unreadable`.',
].join('\n');

// The formats the provider's vision input lists: PNG, JPEG, WEBP and GIF (not
// animated, which this check does not see). Anything else, a TIFF included, is
// refused before it is sent: convert it first.
const SIGNATURES = [
  [/^\x89PNG\r\n\x1a\n/, 'image/png'],
  [/^\xff\xd8\xff/, 'image/jpeg'],
  [/^RIFF[\s\S]{4}WEBP/, 'image/webp'],
  [/^GIF8[79]a/, 'image/gif'],
];

// A cap on what is encoded and uploaded; base64 makes the request a third
// larger than the file. It is not a cost control: the provider bills an image
// by its dimensions, not by its bytes.
export const MAX_IMAGE_BYTES = 8 * 1024 * 1024;

// The provider named here is an example, not a recommendation: the reasoning
// holds for any general-purpose model API, and the client is swappable. Pass
// any object with a `complete({ prompt, image, temperature })` method.
export const MODEL = 'gpt-4.1-mini'; // an example id: check the parameters your model accepts

export async function providerClient(sdk, model = MODEL) {
  if (!sdk) {
    const { OpenAI } = await import('openai');
    sdk = new OpenAI();
  }
  return {
    async complete({ prompt, image, temperature }) {
      // The image travels inside the message, as a data URL.
      const url = `data:${image.mediaType};base64,${image.data}`;
      const response = await sdk.chat.completions.create({
        model,
        messages: [{
          role: 'user',
          content: [{ type: 'text', text: prompt }, { type: 'image_url', image_url: { url } }],
        }],
        temperature,
      });
      return response.choices[0].message.content;
    },
  };
}

export class ReadingUnavailable extends Error {}

/**
 * Transcribe one page image, and say what the model admits it could not read.
 *
 * @param {Buffer|Uint8Array} imageBytes
 * @param {object} options
 * @param {{complete: Function}} [options.client] injected so this can be
 *   tested without a network call; defaults to a real provider client
 * @param {number} [options.attempts]
 * @param {number} [options.maxBytes]
 */
export async function readPage(imageBytes, { client, attempts = 3, maxBytes = MAX_IMAGE_BYTES } = {}) {
  client ??= await providerClient();
  const bytes = Buffer.from(imageBytes);
  const mediaType = mediaTypeOf(bytes);
  if (bytes.length > maxBytes) throw new RangeError(`image larger than ${maxBytes} bytes`);

  const answer = await ask(client, bytes, mediaType, attempts);
  const { text, unreadable = [] } = answer;
  if (typeof text !== 'string' || !Array.isArray(unreadable)) {
    throw new ReadingUnavailable('the model answered JSON that is not a transcription');
  }

  // Two doubts, and neither is a confidence: what the model admits it could
  // not read, and a page that came back empty. See the test file.
  const review = unreadable.length > 0 || text.trim() === '';
  return { text, unreadable: unreadable.map(String), review };
}

/** Read the format from the bytes, rather than trusting a file extension. */
function mediaTypeOf(bytes) {
  const head = bytes.subarray(0, 12).toString('latin1');
  for (const [signature, mediaType] of SIGNATURES) {
    if (signature.test(head)) return mediaType;
  }
  throw new TypeError('unrecognised image format');
}

async function ask(client, bytes, mediaType, attempts) {
  let lastError;
  for (let i = 0; i < attempts; i += 1) {
    try {
      const answer = await client.complete({
        prompt: PROMPT,
        // Base64 is how an image travels in a JSON request body.
        image: { mediaType, data: bytes.toString('base64') },
        // The lowest temperature: the SDK documents lower values as more
        // focused and deterministic.
        temperature: 0,
      });
      if (typeof answer !== 'string') {
        lastError = new Error('the model answered no text'); // a refusal comes back as no content
        continue;
      }
      const parsed = JSON.parse(answer);
      if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) return parsed;
      lastError = new Error('the model answered something that is not an object');
    } catch (error) {
      lastError = error;
    }
  }
  throw new ReadingUnavailable(String(lastError));
}

Ce que ça implique

  • Vos données partent chez un tiers
  • Résultat variable d’un appel à l’autre
  • Difficile à tester
  • Dépend d’un fournisseur externe
  • Empreinte élevée

Ce que cela ajoute à votre périmètre réglementaire

  • Transfert des documents à un sous-traitant, avec l’encadrement contractuel que cela suppose
  • Localisation du traitement à vérifier auprès du fournisseur
  • Une page numérisée peut porter une pièce d’identité ou un document de santé, dont le transfert relève d’un régime distinct

Point de rupture

Un modèle qui lit est un modèle qui écrit. Le test remet une image qui porte un en-tête PNG et rien à lire, et écrit la réponse lui-même dans un double local : une facture entière et plausible — un fournisseur, une référence à la bonne forme, un montant à deux décimales — avec une liste de fragments illisibles vide, donc aucun doute et aucun drapeau de relecture. Toutes les assertions passent, et c’est le sujet : ce qui est prouvé, c’est que la tuyauterie est correcte et qu’elle ne distingue pas une transcription lue d’une transcription écrite. L’appelant reçoit un total qui n’a jamais été imprimé sur aucune page.

Quand passer au niveau suivant

Il n’y a pas de niveau au-dessus.

Pour aller plus loin

Source des chiffres

Métadonnées