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
"""
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.0JavaScript
/**
* 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é.
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.
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.
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.
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.
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.
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
- N0Demander sa couche de texte au document, par une bibliothèque PDFGratuit · Instantané
- N1Niveau sans objet ici
- RecommandéN2Moteur de reconnaissance optique auto-hébergé, avec seuil de relecturemodéré · Une seconde
- 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
"""
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
/**
* 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
"""
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
/**
* 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
- PDF 32000-1:2008 — les opérateurs de texte d’un flux de contenu, section 9.4
- Tesseract OCR — le moteur nommé par l’extrait N2
- Tesseract — Improving the quality of the output, sur ce qui fait rater une page, tableaux compris
- Tesseract — FAQ, sur l’écriture manuscrite
- tesseract.js — le portage JavaScript utilisé par l’extrait N2
Source des chiffres
- PDF 32000-1:2008, 7.3.4.2 (chaînes littérales), 7.5.7 (flux d’objets), 9.4.3 (opérateurs de texte), annexe D (encodages)
- Tesseract — Installation
- tesseract.js — API, createWorker et recognize
- pytesseract — image_to_data
- OpenAI — Images and vision : formats acceptés, facturation par dimensions
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.