Tools,

The Tesseract C++ API, tested on 55 scans

50.8%

of two-column text in order, by default

See the code

The code, run on 55 scans

Install the library first: brew install tesseract on a Mac, the apt packages libtesseract-dev and tesseract-ocr-eng on Debian.

#include <allheaders.h>
#include <tesseract/baseapi.h>
#include <cstdio>

int main(int argc, char **argv) {
  tesseract::TessBaseAPI api;
  if (api.Init(nullptr, "eng")) return 1;
  api.SetPageSegMode(tesseract::PSM_AUTO);
  for (int i = 1; i < argc; i++) {
    Pix *pix = pixRead(argv[i]);
    if (!pix) return 1;
    api.SetImage(pix);
    api.SetSourceResolution(300);
    char *text = api.GetUTF8Text();
    printf("%s", text);
    delete[] text;
    pixDestroy(&pix);
  }
  api.End();
}
clang++ -std=c++17 main.cpp -o ocr $(pkg-config --cflags --libs tesseract lept)

55 of 55 scans, the command's text

13,858 of 14,693 words found, both

Two build errors, two fixes

Tesseract's own example includes <leptonica/allheaders.h>. With Homebrew's pkg-config flags:

fatal error: 'leptonica/allheaders.h' file not found

Include <allheaders.h>. It built on Debian too. Leave out lept, and the link fails on both:

Undefined symbols for architecture arm64:
  "_pixDestroy", referenced from:

pkg-config --libs tesseract lists no Leptonica, the home of pixRead and pixDestroy.

CMake: pkg-config, not find_package

find_package(Tesseract) failed on Homebrew and on Debian: neither installs a CMake config file. This worked on both:

cmake_minimum_required(VERSION 3.16)
project(ocr CXX)
set(CMAKE_CXX_STANDARD 17)
find_package(PkgConfig REQUIRED)
pkg_check_modules(TESS REQUIRED IMPORTED_TARGET tesseract lept)
add_executable(ocr main.cpp)
target_link_libraries(ocr PkgConfig::TESS)

Set page mode 3: the API's is 6

The command reads with page mode 3, automatic layout. The API starts at 6, one block of text. On book pages that changed little. On two columns, it read straight across.

9 of 55 book scans, the command's text

50.8% of letters in order on 30 two-column pages, not 99.2%

Set the dpi after SetImage

My scans have no dpi in the file. Call SetSourceResolution after SetImage: a call before it was lost.

55 of 55 scans, the command's text, called after

40 of 55 called before, the same as never

Setting the variable user_defined_dpi to 300 once, after Init, also gave 55 of 55.

cv::Mat: RGB order and the real step

cv::Mat img = cv::imread("scan.jpg");  // BGR
cv::cvtColor(img, img, cv::COLOR_BGR2RGB);
api.SetImage(img.data, img.cols, img.rows, img.channels(), img.step);
api.SetSourceResolution(300);
55 scans, OpenCV 5.0
Into SetImage Command's text Words
RGB, as above 55 of 55 13,858
BGR, as read 16 of 55 13,854
Gray, as read 17 of 55 13,861

A crop shares rows with the full image. Pass img.cols as bytes per line, and 10 cropped scans gave 0 words, with no error.

Init once, one API per thread

259 line images, CPU seconds
Way CPU
The command per line 48.5 s
Init per line 35.5 s
One Init, as above 12.4 s

Give each thread its own TessBaseAPI. Four threads read the 55 scans in 34.6 s, not 102.2 s, with the same text.

PDFs: render pages with poppler-cpp

Error in pixReadStream: Pdf reading is not supported

Render each page in memory, gray, at 300 dpi. Add poppler-cpp to the pkg-config line:

auto doc = std::unique_ptr<poppler::document>(
    poppler::document::load_from_file("scan.pdf"));
poppler::page_renderer r;
r.set_image_format(poppler::image::format_gray8);
for (int i = 0; i < doc->pages(); i++) {
  std::unique_ptr<poppler::page> page(doc->create_page(i));
  poppler::image img = r.render_page(page.get(), 300, 300);
  api.SetImage((const unsigned char *)img.const_data(), img.width(),
               img.height(), 1, img.bytes_per_row());
  api.SetSourceResolution(300);
  char *text = api.GetUTF8Text();
  printf("%s", text);
  delete[] text;
}

1,882 of 1,955 words on 10 pages

Same bytes as pdftoppm -gray files

Debian in Docker: same text as apt's command

FROM debian:trixie-slim
RUN apt-get update && apt-get install -y --no-install-recommends \
      g++ pkg-config libtesseract-dev tesseract-ocr-eng \
    && rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY main.cpp .
RUN g++ -std=c++17 -O2 main.cpp -o ocr $(pkg-config --cflags --libs tesseract lept)
ENTRYPOINT ["/app/ocr"]

With the code above as main.cpp, 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, color JPEG, 1,920 pixels wide, no dpi in the file. The same set as Tesseract in PHP.
  • Two columns: the 30 encyclopedia scans of the two-column test, letters found in order.
  • Score: true words found in order, with the scorer of the tessdata test.
  • Apple Silicon Mac, Apple clang 21, Homebrew Tesseract 5.5.4, Leptonica 1.87.0, OpenCV 5.0.0, Poppler 26.03.0, tessdata_fast English.
  • CPU time of the whole process, on the Mac's efficiency cores. The command's text is compared with its last form feed dropped.
  • PDF: the first 10 scans in one PDF, JPEG bytes kept. The JPEG files themselves gave 1,878 words.
What I did not test
  • Windows, Visual Studio, vcpkg, Intel Macs, x86-64 Linux.
  • The C API (capi.h), Qt images, and the PDF renderer.
  • Other Tesseract versions, 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 C++ API give the same text as the command?

Yes, on 55 of 55 scans, with page mode 3 and 300 dpi set. With the API's defaults, 9 of 55 matched.

Why does the linker say pixRead is undefined?

pixRead is in Leptonica, and pkg-config tesseract does not list it. Add lept: pkg-config --cflags --libs tesseract lept.

Can the API read a PDF?

No. Leptonica reads images only. Render each page to an image first, for example with poppler-cpp at 300 dpi in gray, then pass the pixels to SetImage.