Tools,

Tesseract OCR in PHP, tested on 55 scans

3.2x

less CPU: one call, many images

See the code

The code, run on 55 scans

The package runs the tesseract command, so install that first: brew install tesseract on a Mac, apt-get install tesseract-ocr on Debian or Ubuntu.

composer require thiagoalessio/tesseract_ocr:2.13.0
<?php
require __DIR__ . '/vendor/autoload.php';

use thiagoalessio\TesseractOCR\TesseractOCR;

echo (new TesseractOCR('scan.jpg'))->dpi(300)->run();

55 of 55 scans, the command's text

13,858 of 14,693 words found, both

Works in the terminal, fails on the web

The same code under php-fpm, from Homebrew's PHP:

Error! The command "tesseract" was not found.

Make sure you have Tesseract OCR installed on your system:
https://github.com/tesseract-ocr/tesseract

The current $PATH is

The empty $PATH is the clue. php-fpm clears the environment of its workers by default, so PHP cannot see /opt/homebrew/bin. Either fix worked:

echo (new TesseractOCR('scan.jpg'))
    ->executable('/opt/homebrew/bin/tesseract')
    ->dpi(300)->run();

Or add this line to the pool's www.conf and restart php-fpm:

env[PATH] = /opt/homebrew/bin:/usr/bin:/bin

Set the dpi to match the command

My scans have no dpi in the file, so without ->dpi(300) Tesseract guesses it. The page mode needs nothing: the default is psm 3, like the command's.

40 of 55 scans, the command's text without it

13,855 words found, 3 fewer

Same text, more CPU on small images

CPU seconds, Tesseract 5.5.4
Job shell_exec Wrapper
55 full pages 143.0 s 144.6 s
259 line images 45.4 s 54.5 s

Before each image, the wrapper starts tesseract once more to read its version. A big page hides that. A small image does not.

2 tesseract starts per image

4 with imageData() and withoutTempFiles()

Many small images: one call

Tesseract reads a text file of image paths in one run. The model loads once:

$list = tempnam(sys_get_temp_dir(), 'list');
file_put_contents($list, implode("\n", $files) . "\n");
$text = (new TesseractOCR($list))
    ->dpi(300)->psm(7)
    ->config('page_separator', '[page]')
    ->run();
unlink($list);
$texts = explode('[page]', $text);
if (count($texts) !== count($files)) {
    throw new RuntimeException('Tesseract stopped early.');
}

17.1 s for 259 lines, not 54.5 s

All 259 lines, the command's text

Keep the count check. A file Tesseract cannot read stops the run, and the wrapper returns the text so far with no error.

The wrapper trims form feeds, the default separator, from both ends. With a blank last image, 3 images came back as 2 parts.

PDFs: make gray images first

Two lines of the wrapper's PDF error:

Error! The command did not produce any output.
Error in pixReadStream: Pdf reading is not supported

Make an image of each page at 300 dpi first:

$dir = sys_get_temp_dir() . '/pdf-' . uniqid();
mkdir($dir);
exec('pdftoppm -r 300 -gray ' . escapeshellarg('scan.pdf') . ' ' . escapeshellarg("$dir/page"));
$text = '';
foreach (glob("$dir/page-*") as $img) {
    $text .= (new TesseractOCR($img))->dpi(300)->run() . "\n";
    unlink($img);
}
rmdir($dir);
10 pages, 1,955 true words
Pages to images CPU a page Words
pdftoppm -png 13.1 s 1,879
pdftoppm -gray 2.4 s 1,882
pdfimages -all 3.0 s 1,878

PNG writing took most of the PNG run. -gray writes plain PGM files.

Debian in Docker: same text as apt's command

FROM php:8.4-cli
RUN apt-get update \
 && apt-get install -y --no-install-recommends tesseract-ocr unzip \
 && rm -rf /var/lib/apt/lists/*
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
WORKDIR /app
RUN composer require thiagoalessio/tesseract_ocr:2.13.0
COPY index.php scan.jpg ./
CMD ["php", "index.php"]

With the code above as index.php, it matched apt's tesseract (5.5.0) on 5 of 5 scans.

How I tested it
  • Pages: 55 proofread Wikisource scans from 11 printed books, JPEG, 1,920 pixels wide, no dpi in the file. The same set as Tesseract in C#.
  • Score: true words found in order, with the scorer of the tessdata test.
  • Apple Silicon Mac, PHP 8.5.12 and Composer 2.11.1 from Homebrew, the wrapper 2.13.0, Tesseract 5.5.4, tessdata_fast English.
  • CPU time of the whole process tree, PHP start included, on the Mac's efficiency cores. A second round of the line jobs gave 52.8 s, 60.9 s and 18.0 s.
  • Web: Homebrew's php-fpm 8.5.12 with the default clear_env, one scan, through a FastCGI client.
  • PDF: the first 10 scans in one PDF, JPEG bytes kept, page size for 300 dpi. pdftoppm 26.03.0.
What I did not test
  • Windows, Intel Macs, x86-64 Linux, and php-fpm on Linux.
  • Apache with mod_php, Laravel Herd and Valet.
  • Imagick for PDFs, and other wrapper versions.
  • Other languages, tessdata_best, and photos.
Sources

Two ways to skip the work

Run it yourself. Our Apify Actor turns scanned PDFs into Markdown, tables included.

Try the Actor

Or send us the scan. Tack, our AI, does the job for $29, up to 50 pages. First page free.

Get one page free

Questions people ask

Does the wrapper give the same text as the command?

Yes, on 55 of 55 scans, with the same Tesseract, the same model, psm 3 and 300 dpi on both. It runs the command and trims the ends.

Why does it work in the terminal but not in the browser?

php-fpm clears the environment of its workers by default, so PHP has no $PATH and cannot see Homebrew's tesseract. Pass the full path with executable(), or set env[PATH] in the pool.

Can it read a PDF?

No. Tesseract reads images only. Make an image of each page first, for example with pdftoppm -r 300 -gray, then read each image.