Read the text of a scanned page
Get the text out of a document that arrived as a PDF or an image, to index it, file it or read it back.
AI, but a small model on your own machines
modéréAbout a secondYour data stays with you
The verdict
N2, because on a page that was really scanned nothing is left but the pixels, and this is the only level that reads them on your own machine, with no key and no quota. N0 comes first every time: it costs one function call, it settles the whole need when the document was carrying its text all along, and when it is not, it says so instead of handing back a blank page — a first reflex, not a general solution. N3 is left for what Tesseract itself says it reads badly, handwriting and tables, with nothing here proving the model reads them any better, and it pays with the one thing that matters: its transcription may have been written rather than read, and nothing in the code can tell.
Self-hosted optical recognition engine, with a review threshold
AI, but a small model on your own machines
Small self-hosted specialised modelN2modéréAbout a second
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, and there is no key and no quota.
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 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
/**
* 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, and there is no key and no quota.
*
* 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 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;
}Try it
This code cannot run in a browser. The outputs below are computed when the site is built, by running the real code.
What the engine reads in the pixels is simulated by the local double the tests use. What is computed here is the cleaning of the text that comes back, the threshold under which the page goes to review, and what the code does with a dropped call.
A clean invoice scan
Given
scan-facture-000431.png — the Northern Supplies invoice, laid flat
What it returns
NORTHERN SUPPLIES LTD Invoice 2024-000431 Issued 03/04/2024 TOTAL DUE 92.40 EUR
Confidence 0.94, above the threshold of 0.7: the page is filed without review. One call, on your own machine, with no key and no quota.
A word cut by the end of a line
Given
scan-bon-livraison.png — a delivery note where “dupli- cate” sits across two lines
What it returns
Please attach a second duplicate of the invoice to the delivery note.
Confidence 0.91, above the threshold of 0.7: the page is filed without review. One call, on your own machine, with no key and no quota.
A faint fax
Given
fax-facture-000431.png — the same invoice, received by fax
What it returns
NORTHERN SUPPLIES LTD Invoice 2024-000431 Issued 03/04/2024 TOTAL DUE 92.40 EUR
Confidence 0.41, below the threshold of 0.7: the text is kept and the page goes to human review. One call, on your own machine, with no key and no quota.
The blank back of a page
Given
scan-verso-vierge.png — the back of a sheet, scanned along with the rest
What it returns
nothing
The engine claims 0.99 confidence and returned no text at all: the page goes to review, because a blank page is not a reading.
A call drops
Given
scan-facture-000431.png — the same page, but the first call fails
What it returns
NORTHERN SUPPLIES LTD Invoice 2024-000431 Issued 03/04/2024 TOTAL DUE 92.40 EUR
The first call dropped, the second answered. Confidence 0.94, above the threshold of 0.7: the page is filed without review. 2 calls, on your own machine, with no key and no quota.
A zero read as an O
Given
scan-facture-000431-bis.png — a clean scan, glyphs well separated
What it returns
Invoice 2O24-OOO431 TOTAL DUE 92.40 EUR
Confidence 0.96, above the threshold of 0.7: the page is filed without review. One call, on your own machine, with no key and no quota.
Here it breaksThe invoice carries the reference 2024-000431; the engine returns “2O24-OOO431”, with the letter O in place of the zero, and a confidence of 0.96. The review flag stays down, because the score clears the threshold. A threshold raised above 0.96 would raise it, but on every correct reading returned at the same score as well: it does not tell the wrong reading from the right one. What catches it is a rule about the shape your references take, written by hand: level N0 again.See the level above
What this means for you
- Your data stays on your own machines
- Always the same result
- Measured on a sample, not case by case
- Depends on a library
- Moderate footprint
What this adds to your regulatory scope
- Processing of the documents on your own infrastructure; only the engine’s language data comes from outside, which tesseract.js downloads from a CDN by default
- Pages sent to human review are seen by a person: internal access is part of the scope and is handled as such
Breaking point
A confident mistake, and a confidence threshold can do nothing about it: « N° 2O24-OOO431 », the letter O where the invoice printed a zero, returned with a high score and therefore no review flag. Raising the threshold above that score would raise the flag, but on the correct readings scored the same as well — a threshold catches a page the engine struggled with, it cannot tell a page read wrong from a page read right at the same score, and that blindness is what the test demonstrates, on a reading written by the double. What catches the mistake is a rule about the shape your references take, written by hand: level N0 again.
When to move up a level
Your pages carry what Tesseract’s own documentation says it reads badly: handwriting, which the engine is not designed for, or tables, which it struggles with unless you write the segmentation for them — and you have already done what that same documentation asks for first, binarise, deskew and scan at three hundred dots per inch, which does more for a reading than the choice of threshold.
The four options
- N0Ask the document for its text layer, through a PDF libraryFree · Instant
- N1Not worth it here
- RecommendedN2Self-hosted optical recognition engine, with a review thresholdmodéré · About a second
- N3Transcription through a general-purpose multimodal modelélevé · Over a second
The other options
N0Ask the document for its text layer, through a PDF libraryFree · Instant
Ask the document for its text layer, through a PDF library
Rule and classic algorithmN0FreeInstant
Python
"""
Ask the document whether it already carries its text, before reaching for OCR.
Rung N0. One question, put to a PDF library, and an answer the caller can act
on.
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. A scan of the same
document carries pixels. The two look alike in a mailbox, and nothing
downstream tells them apart unless somebody asks.
The question is the point of this rung; the extractor is not. Writing the
extractor is the trap: inflating page streams and decoding the text-showing
operators is two hundred lines that still lose to the first font the file
renumbers for itself, which is what a word processor does on every export.
That work is done and shipped — `pypdf` here, `pdf.js` in JavaScript,
`pdftotext` at a shell prompt — and what is left to write is the decision.
Which is this: a page that carries no readable text is not an empty page, and
the function says so instead of handing back an empty string a caller would
read as « the page is blank ». A no comes with its next step.
"""
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
/**
* Ask the document whether it already carries its text, before reaching for OCR.
*
* Rung N0. One question, put to a PDF library, and an answer the caller can
* act on.
*
* 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. A scan of the
* same document carries pixels. The two look alike in a mailbox, and nothing
* downstream tells them apart unless somebody asks.
*
* The question is the point of this rung; the extractor is not. Writing the
* extractor is the trap: inflating page streams and decoding the text-showing
* operators is two hundred lines that still lose to the first font the file
* renumbers for itself, which is what a word processor does on every export.
* That work is done and shipped — `pdf.js` here, `pypdf` in Python,
* `pdftotext` at a shell prompt — and what is left to write is the decision.
*
* Which is this: a page that carries no readable text is not an empty page,
* and the function says so instead of handing back an empty string a caller
* would read as « the page is blank ». A no comes with its next step.
*/
// 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 };
}What this means for you
- Your data stays put
- Always the same result
- Unit-testable, case by case
- Depends on a library
- Negligible footprint
What this adds to your regulatory scope
- No specific scope added: the document never leaves your infrastructure
- A digitised page often carries personal data, processed here on your own infrastructure alone
Breaking point
A page that was really scanned has no text layer, and no library will invent one: the test document is valid, it has one page, and that page is a photograph drawn edge to edge. What the snippet returns is not an empty string, which would read as « this page is blank » and file an unread document, but a report that names the reason and points at OCR — that is all this level can do, and it does not pretend to read pixels.
When to move up a level
The report comes back « no text layer » on documents you still have to read — scans, then, and that is the moment to look at the pixels.
N1Not worth it here
Not worth it here
Lightweight classic modelN1
A hand-written OCR — line and character segmentation, then a classifier on each glyph — redoes what Tesseract already does, a free engine the common distributions ship as a package, installed with a single command. On this entry, N2 is not a step up in power, it is the sensible entry point.
N3Transcription through a general-purpose multimodal modelélevé · Over a second
Transcription through a general-purpose multimodal model
General-purpose LLM APIN3élevéOver a second
Python
"""
Read a scanned page by handing the image to a general-purpose multimodal model.
Rung N3. The page goes out whole, to a model that returns a transcription.
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
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
/**
* Read a scanned page by handing the image to a general-purpose multimodal
* model.
*
* Rung N3. The page goes out whole, to a model that returns a transcription.
*
* 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 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));
}What this means for you
- Your data goes to a third party
- The result varies from one call to the next
- Hard to test
- Depends on an outside provider
- High footprint
What this adds to your regulatory scope
- Transfer of the documents to a processor, with the contractual framing that implies
- Processing location to be confirmed with the provider
- A digitised page may hold an identity document or a health record, whose transfer falls under a separate regime
Breaking point
A model that reads is a model that writes. The test hands over an image carrying a PNG header and nothing to read, and writes the answer itself, into a local double: a complete, plausible invoice — a supplier, a reference in the right shape, an amount with two decimals — with an empty list of unreadable fragments, so no doubt and no review flag. Every assertion passes, and that is the point: what is proved is that the plumbing is correct and that it cannot tell a transcription that was read from one that was written. The caller receives a total that was never printed on any page.
When to move up a level
There is no level above this one.
Further reading
- 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
Where the figures come from
- 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
Metadata
General information, kept up to date as best we can. It is not legal advice. Have a professional review your situation.