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.
RecommandéN2 Révisée le
| Barreau | Approche | Coût | Latence | Données | Déterministe | Verdict |
|---|---|---|---|---|---|---|
| N0 — Règle et algorithme classique | Lire la couche de texte du document avant de sortir un OCR | Nul | ~10 ms | Rien ne sort | Oui | |
| N1 — Modèle classique léger | Barreau absent Un OCR écrit à la main — segmentation des caractères, puis classifieur sur chaque glyphe — est un projet de plusieurs mois, dont le résultat reste en deçà des moteurs libres qui s'installent en une commande. Sur cette fiche, N2 n'est pas une montée en puissance, c'est le point d'entrée raisonnable. | |||||
| N2 — Petit modèle spécialisé auto-hébergé | Moteur de reconnaissance optique auto-hébergé, avec seuil de relecture | Modéré | ~1 s | Reste dans votre infrastructure | Oui | Recommandé |
| N3 — API de LLM généraliste | Transcription par appel à un modèle généraliste multimodal | Élevé | >1 s | Part chez un tiers | Non | |
N0 — Règle et algorithme classique Règle et algorithme classique
Lire la couche de texte du document avant de sortir un OCR
- Coût
- Nul
- Latence
~10 ms
Preuve d’exécution : Code exécuté tel quel
Cet extrait s’exécute avec ses vraies dépendances, et son test tourne à chaque construction du site.
Python
"""
Read the text layer the document may already carry, before reaching for OCR.
Rung N0. Standard library only: zlib to inflate the page streams, and a
reading of the text-showing operators inside them.
Most « scanned pages » were never scanned. A PDF produced by an accounting
tool, a word processor or a print-to-PDF driver carries its text next to its
drawing instructions, already correct, with no recognition step and therefore
nothing to get wrong. Asking the question first is one function call, and it
answers the whole need whenever the answer is yes.
The point is the question, not the extractor. When the answer is no, this
function says so — it does not hand back an empty string, which a caller would
read as « the page is blank ».
"""
from __future__ import annotations
import re
import zlib
# Under this many non-space 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
STREAM = re.compile(rb"stream\r?\n(.*?)[\r\n]*endstream", re.S)
PAGE = re.compile(rb"/Type\s*/Page[^s]")
# Inside a content stream: a string being shown, or an operator that moves the
# cursor to another line. Kerning numbers inside a TJ array are skipped on
# purpose — they space glyphs, they do not carry characters.
TOKEN = re.compile(rb"\((?:\\.|[^\\()])*\)|<[0-9A-Fa-f\s]*>|\bTd\b|\bTD\b|\bT\*|\bET\b", re.S)
ESCAPES = {b"n": b"\n", b"r": b"\r", b"t": b"\t", b"b": b"\b", b"f": b"\f"}
ESCAPE = re.compile(rb"\\(?:([0-7]{1,3})|(.))", re.S)
def read_text_layer(pdf_bytes: bytes, *, min_characters: int = MIN_CHARACTERS) -> 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.
"""
chunks = [t for t in (_read_stream(s) for s in STREAM.findall(pdf_bytes)) if t]
text = "\n".join(chunks)
characters = sum(1 for c in text if not c.isspace())
has_text_layer = characters >= min_characters
return {
"has_text_layer": has_text_layer,
"text": text,
"characters": characters,
"pages": len(PAGE.findall(pdf_bytes)),
"reason": None if has_text_layer else "no text layer: this page is an image, and needs OCR",
}
def _read_stream(raw: bytes) -> str:
"""Inflate the stream if it is compressed, then read what it shows."""
try:
# decompressobj, not decompress: a stream may carry padding after the
# deflated data, and a raw decompress would refuse it.
data = zlib.decompressobj().decompress(raw)
except zlib.error:
data = raw # an uncompressed content stream is perfectly legal
lines: list[str] = []
current: list[str] = []
for token in TOKEN.findall(data):
if token.startswith(b"("):
current.append(_literal(token[1:-1]))
elif token.startswith(b"<"):
current.append(_hex(token[1:-1]))
elif current:
lines.append("".join(current))
current = []
if current:
lines.append("".join(current))
return "\n".join(lines)
def _literal(body: bytes) -> str:
"""A literal string: backslash escapes, and octal for everything else."""
def replace(match: re.Match) -> bytes:
octal, char = match.groups()
if octal:
return bytes([int(octal, 8) & 0xFF])
return ESCAPES.get(char, char)
# Latin-1, because a simple font encodes one byte per character. A font
# with its own encoding table needs that table, which is another job.
return ESCAPE.sub(replace, body).decode("latin-1")
def _hex(body: bytes) -> str:
"""A hex string, as PDF writers emit for anything beyond ASCII."""
digits = re.sub(rb"\s", b"", body)
if len(digits) % 2:
digits += b"0" # the spec pads a lone last digit with zero
raw = bytes.fromhex(digits.decode("ascii"))
if raw[:2] == b"\xfe\xff":
return raw[2:].decode("utf-16-be", errors="replace")
return raw.decode("latin-1")JavaScript
/**
* Read the text layer the document may already carry, before reaching for OCR.
*
* Rung N0. Standard library only: node:zlib to inflate the page streams, and
* a reading of the text-showing operators inside them.
*
* Most « scanned pages » were never scanned. A PDF produced by an accounting
* tool, a word processor or a print-to-PDF driver carries its text next to its
* drawing instructions, already correct, with no recognition step and
* therefore nothing to get wrong. Asking the question first is one function
* call, and it answers the whole need whenever the answer is yes.
*
* The point is the question, not the extractor. When the answer is no, this
* function says so — it does not hand back an empty string, which a caller
* would read as « the page is blank ».
*/
import zlib from 'node:zlib';
// Under this many non-space 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;
const STREAM = /stream\r?\n([\s\S]*?)[\r\n]*endstream/g;
const PAGE = /\/Type\s*\/Page[^s]/g;
// Inside a content stream: a string being shown, or an operator that moves the
// cursor to another line. Kerning numbers inside a TJ array are skipped on
// purpose — they space glyphs, they do not carry characters.
const TOKEN = /\((?:\\[\s\S]|[^\\()])*\)|<[0-9A-Fa-f\s]*>|\bTd\b|\bTD\b|\bT\*|\bET\b/g;
const ESCAPES = { n: '\n', r: '\r', t: '\t', b: '\b', f: '\f' };
const ESCAPE = /\\(?:([0-7]{1,3})|([\s\S]))/g;
/**
* Say whether the document carries a text layer, and return it if it does.
*
* The answer is a report, not a string: `hasTextLayer` is the decision the
* caller acts on, and `reason` is what to tell them when it is false.
*
* @param {Buffer|Uint8Array} pdfBytes
* @param {{minCharacters?: number}} [options]
*/
export function readTextLayer(pdfBytes, { minCharacters = MIN_CHARACTERS } = {}) {
// Latin-1 maps each byte to one character, so a regular expression can walk
// the file without ever corrupting the compressed parts it steps over.
const document = Buffer.from(pdfBytes).toString('latin1');
const chunks = [];
for (const [, stream] of document.matchAll(STREAM)) {
const text = readStream(Buffer.from(stream, 'latin1'));
if (text) chunks.push(text);
}
const text = chunks.join('\n');
const characters = [...text].filter((c) => !/\s/.test(c)).length;
const hasTextLayer = characters >= minCharacters;
return {
hasTextLayer,
text,
characters,
pages: (document.match(PAGE) ?? []).length,
reason: hasTextLayer ? null : 'no text layer: this page is an image, and needs OCR',
};
}
/** Inflate the stream if it is compressed, then read what it shows. */
function readStream(raw) {
let data;
try {
// Z_SYNC_FLUSH, because a stream may carry padding after the deflated
// data, and a strict inflate would refuse it.
data = zlib.inflateSync(raw, { finishFlush: zlib.constants.Z_SYNC_FLUSH }).toString('latin1');
} catch {
data = raw.toString('latin1'); // an uncompressed content stream is legal
}
const lines = [];
let current = [];
for (const [token] of data.matchAll(TOKEN)) {
if (token.startsWith('(')) current.push(literal(token.slice(1, -1)));
else if (token.startsWith('<')) current.push(hexString(token.slice(1, -1)));
else if (current.length) {
lines.push(current.join(''));
current = [];
}
}
if (current.length) lines.push(current.join(''));
return lines.join('\n');
}
/**
* A literal string: backslash escapes, and octal for everything else.
*
* The result stays in Latin-1, because a simple font encodes one byte per
* character. A font with its own encoding table needs that table, which is
* another job.
*/
function literal(body) {
return body.replace(ESCAPE, (_, octal, char) =>
octal ? String.fromCharCode(parseInt(octal, 8) & 0xff) : (ESCAPES[char] ?? char),
);
}
/** A hex string, as PDF writers emit for anything beyond ASCII. */
function hexString(body) {
let digits = body.replace(/\s/g, '');
if (digits.length % 2) digits += '0'; // the spec pads a lone last digit with zero
const raw = Buffer.from(digits, 'hex');
if (raw[0] === 0xfe && raw[1] === 0xff) {
return Buffer.from(raw.subarray(2)).swap16().toString('utf16le');
}
return raw.toString('latin1');
}Risques
- Sortie de données
- Rien ne sort
- Déterminisme
- Oui
- Testabilité
- Testable unitairement
- Dépendance fournisseur
- Aucune
- Empreinte
- Négligeable
- 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 lecture n'en inventera une. Le document du test est valide, il compte une page, et cette page est une photographie dessinée en plein cadre : zéro caractère. Ce que l'extrait rend alors n'est 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. Le tampon d'un numéro de page ne suffit pas non plus à faire une couche de texte : un caractère posé sur une image reste une image.
Quand monter d’un barreau
Le rapport revient avec « pas de couche de texte » sur des documents qu'il faut bien lire.
N1 — Modèle classique léger Modèle classique léger
Barreau absent
Un OCR écrit à la main — segmentation des caractères, puis classifieur sur chaque glyphe — est un projet de plusieurs mois, dont le résultat reste en deçà des moteurs libres qui s'installent en une commande. Sur cette fiche, N2 n'est pas une montée en puissance, c'est le point d'entrée raisonnable.
N2 — Petit modèle spécialisé auto-hébergé Petit modèle spécialisé auto-hébergé Recommandé
Moteur de reconnaissance optique auto-hébergé, avec seuil de relecture
- Coût
- Modéré
- Latence
~1 s
Preuve d’exécution : Code exécuté, service externe simulé
Cet extrait s’exécute à chaque construction du site, mais son test remplace le service externe par un double local. Ce qui est vérifié : la requête envoyée, la réponse décodée et les cas d’erreur. Ce qui ne l’est pas : la qualité de la réponse du modèle.
Python
"""
Read a scanned page with a self-hosted optical recognition engine.
Rung N2. When N0 answered « no text layer », something has to look at the
pixels. An engine like Tesseract does exactly that, on your machine: the page
never leaves it, there is no key and no quota, and the same image gives the
same reading every time.
What you own on this rung is not the engine, it is everything around it: the
language you tell it to expect, the confidence under which a page goes to a
human, the cleaning of a text that comes back broken across lines, and the
answer to « what does the code do when the engine says nothing usable ».
"""
from __future__ import annotations
import re
# The language matters more than anything else you can tune here: an engine
# reading French with an English model invents accents it has never seen.
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."""
class TesseractOCR:
"""The real engine, started once and kept for the process."""
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, 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,
)
# Word-level scores, on a hundred-point scale, and a score of -1 for
# the layout boxes that carry no word at all.
scores = [float(c) for w, c in zip(data["text"], data["conf"]) if w.strip() and float(c) >= 0]
return {
"text": "\n".join(data["text"]),
"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 TesseractOCR()
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 page 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.
return re.sub(r"(\w)-\n(\w)", 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
/**
* Read a scanned page with a self-hosted optical recognition engine.
*
* Rung N2. When N0 answered « no text layer », something has to look at the
* pixels. An engine like Tesseract does exactly that, on your machine: the
* page never leaves it, there is no key and no quota, and the same image gives
* the same reading every time.
*
* What you own on this rung is not the engine, it is everything around it: the
* language you tell it to expect, the confidence under which a page goes to a
* human, the cleaning of a text that comes back broken across lines, and the
* answer to « what does the code do when the engine says nothing usable ».
*/
// The language matters more than anything else you can tune here: an engine
// reading French with an English model invents accents it has never seen.
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, started once and kept for the process. */
export class TesseractOCR {
static async load(language = LANGUAGE) {
const { createWorker } = await import('tesseract.js'); // pulls the language data once
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);
// Word-level scores, on a hundred-point scale.
const scores = data.words.filter((w) => w.text.trim()).map((w) => w.confidence);
const total = scores.reduce((sum, score) => sum + score, 0);
return { text: data.text, confidence: scores.length ? total / (100 * scores.length) : 0 };
}
}
/**
* 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 TesseractOCR.load());
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 page 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.
return lines.join('\n').replace(/(\w)-\n(\w)/g, (_, 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;
}Risques
- Sortie de données
- Reste dans votre infrastructure
- Déterminisme
- Oui
- Testabilité
- Testable statistiquement
- Dépendance fournisseur
- Bibliothèque
- Empreinte
- Modérée
- Périmètre réglementaire
-
- Traitement des documents sur votre infrastructure, données linguistiques du moteur comprises
- 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. Sur un scan net, le moteur lit la lettre O là où la facture imprimait un zéro et rend « N° 2O24-OOO431 » avec un score haut : le drapeau de relecture reste baissé, puisque le moteur n'a pas hésité. Relever le seuil n'y change rien, l'erreur est confiante. Ce qui la rattrape, c'est une règle sur la forme attendue de vos références, écrite à la main : N0 de nouveau.
Quand monter d’un barreau
Vos pages portent ce qu'un moteur de lignes ne rend pas : une annotation manuscrite en marge, un tampon en travers d'un tableau, deux colonnes qu'il aplatit en une seule suite de lignes.
N3 — API de LLM généraliste API de LLM généraliste
Transcription par appel à un modèle généraliste multimodal
- Coût
- Élevé
- Latence
>1 s
Preuve d’exécution : Code exécuté, service externe simulé
Cet extrait s’exécute à chaque construction du site, mais son test remplace le service externe par un double local. Ce qui est vérifié : la requête envoyée, la réponse décodée et les cas d’erreur. Ce qui ne l’est pas : la qualité de la réponse du modèle.
Python
"""
Read a scanned page by handing the image to a general-purpose multimodal model.
Rung N3. This is the option people reach for first, and on this entry it does
buy something real: a model that sees the page reads a handwritten annotation
in the margin, a stamp across a table, a column layout an OCR engine flattens.
Note what the code has to do that N0 did not: recognise the image format, cap
what it sends, encode the bytes, retry on failure, parse an answer that is only
probably valid JSON, and refuse an answer it cannot use. That plumbing is the
real cost of this rung, and it is the part the tests have to cover, because the
model itself is not testable.
And note what none of that plumbing can do: tell whether the transcription is
what the page says. A model that reads is also a model that writes.
"""
from __future__ import annotations
import base64
import json
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 signatures a scanner produces. Anything else is refused rather than sent
# and charged for, because a provider will reject it too.
SIGNATURES = ((b"\x89PNG\r\n\x1a\n", "image/png"), (b"\xff\xd8\xff", "image/jpeg"),
(b"II*\x00", "image/tiff"), (b"MM\x00*", "image/tiff"))
# A page scan larger than this is a photograph of a desk, not a page. A model
# charges by what it is given, so refusing it is a cost control, not an
# optimisation.
MAX_IMAGE_BYTES = 8 * 1024 * 1024
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.
"""
if client is None: # pragma: no cover - needs a key and a network
from openai import OpenAI
client = OpenAI()
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")
# What the model admits it could not read is the only doubt it reports.
# It is worth having, and it is not a confidence: see the test file.
return {"text": text, "unreadable": [str(u) for u in unreadable], "review": bool(unreadable)}
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 image_bytes.startswith(signature):
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")},
# Temperature zero: a transcription that changes between two
# identical calls cannot be checked by anyone.
temperature=0,
)
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
/**
* Read a scanned page by handing the image to a general-purpose multimodal
* model.
*
* Rung N3. This is the option people reach for first, and on this entry it
* does buy something real: a model that sees the page reads a handwritten
* annotation in the margin, a stamp across a table, a column layout an OCR
* engine flattens.
*
* Note what the code has to do that N0 did not: recognise the image format,
* cap what it sends, encode the bytes, retry on failure, parse an answer that
* is only probably valid JSON, and refuse an answer it cannot use. That
* plumbing is the real cost of this rung, and it is the part the tests have to
* cover, because the model itself is not testable.
*
* And note what none of that plumbing can do: tell whether the transcription
* is what the page says. A model that reads is also a model that writes.
*/
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 signatures a scanner produces. Anything else is refused rather than sent
// and charged for, because a provider will reject it too.
const SIGNATURES = [
[[0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a], 'image/png'],
[[0xff, 0xd8, 0xff], 'image/jpeg'],
[[0x49, 0x49, 0x2a, 0x00], 'image/tiff'],
[[0x4d, 0x4d, 0x00, 0x2a], 'image/tiff'],
];
// A page scan larger than this is a photograph of a desk, not a page. A model
// charges by what it is given, so refusing it is a cost control, not an
// optimisation.
export const MAX_IMAGE_BYTES = 8 * 1024 * 1024;
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 } = {}) {
if (!client) {
// Needs a key and a network, so it is never reached in the tests.
const { OpenAI } = await import('openai');
client = new OpenAI();
}
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');
}
// What the model admits it could not read is the only doubt it reports. It
// is worth having, and it is not a confidence: see the test file.
return { text, unreadable: unreadable.map(String), review: unreadable.length > 0 };
}
/** Read the format from the bytes, rather than trusting a file extension. */
function mediaTypeOf(bytes) {
for (const [signature, mediaType] of SIGNATURES) {
if (signature.every((byte, i) => bytes[i] === byte)) 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') },
// Temperature zero: a transcription that changes between two identical
// calls cannot be checked by anyone.
temperature: 0,
});
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));
}Risques
- Sortie de données
- Part chez un tiers
- Déterminisme
- Non
- Testabilité
- Difficilement testable
- Dépendance fournisseur
- Fournisseur externe
- Empreinte
- Élevée
- 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 lui remet une image qui porte un en-tête PNG et rien à lire ; il rend 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 : la tuyauterie est correcte, et l'appelant reçoit un total qui n'a jamais été imprimé sur aucune page.
Quand monter d’un barreau
Il n'y a pas de barreau au-dessus.
Le verdict
RecommandéN2
N2, parce que sur une page réellement scannée il ne reste que les pixels, et que c'est le seul barreau qui les lise chez vous, sans clé ni quota, et deux fois de la même façon. 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 lit ce que N2 aplatit, une annotation en marge ou deux colonnes, et le paie de la seule chose qui compte ici : sa transcription peut avoir été écrite plutôt que lue, et rien dans le code ne peut s'en apercevoir.
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
- tesseract.js — le portage JavaScript utilisé par l'extrait N2