OCR a scanned PDF

Make a scanned PDF searchable through the API, in up to three of ten languages.

Before you start

  • An API key with the tools:run scope to upload and run, and jobs:read to follow the run and download the result. Authentication shows how to make one.
  • A Team, Business or Enterprise account. The API is included in those plans. The largest file and the number of files in one run follow the plan: for the Team plan, 250 MB and 50. The rate limits page lists every plan.
  • The tool id is ocr. GET https://api.pdfloft.com/v1/tools/ocr returns the files and options it takes, and the Tools and options page lists them all.

The whole script

The script uploads a scanned PDF, starts a run of ocr, waits for it and saves the first result. Upload and run are separate calls: POST /v1/files returns a file id, and POST /v1/tools/ocr/runs takes the ids. The Idempotency-Key header lets you send the run call again after a network failure without starting a second run. A run is queued, running, then succeeded, failed or cancelled.

curl
FILE0=$(curl -s -X POST https://api.pdfloft.com/v1/files \
  -H "Authorization: Bearer $PDFLOFT_API_KEY" \
  -H "Content-Type: application/pdf" \
  --data-binary @scan.pdf | jq -r .id)

JOB=$(curl -s -X POST https://api.pdfloft.com/v1/tools/ocr/runs \
  -H "Authorization: Bearer $PDFLOFT_API_KEY" \
  -H "Idempotency-Key: $(uuidgen)" \
  -H "Content-Type: application/json" \
  -d '{"files":["'"$FILE0"'"],"options":{"languages":["eng"]}}' | jq -r .id)

until [ "$(curl -s https://api.pdfloft.com/v1/jobs/$JOB -H "Authorization: Bearer $PDFLOFT_API_KEY" | jq -r .status)" = "succeeded" ]; do sleep 1; done

URL=$(curl -s https://api.pdfloft.com/v1/jobs/$JOB/results/0 -H "Authorization: Bearer $PDFLOFT_API_KEY" | jq -r .url)
curl -s -o searchable.pdf "$URL"
JavaScript
import { readFile, writeFile } from 'node:fs/promises';

const apiKey = '<your API key>'; // read it from your secret store, never from source
const headers = { Authorization: `Bearer ${apiKey}` };

async function upload(path, contentType) {
  const response = await fetch('https://api.pdfloft.com/v1/files', {
    method: 'POST',
    headers: { ...headers, 'Content-Type': contentType },
    body: await readFile(path),
  });
  return response.json();
}

const file0 = await upload('scan.pdf', 'application/pdf');

let job = await fetch('https://api.pdfloft.com/v1/tools/ocr/runs', {
  method: 'POST',
  headers: { ...headers, 'Content-Type': 'application/json', 'Idempotency-Key': crypto.randomUUID() },
  body: JSON.stringify({ files: [file0.id], options: {"languages":["eng"]} }),
}).then((r) => r.json());

while (job.status === 'queued' || job.status === 'running') {
  await new Promise((resolve) => setTimeout(resolve, 1000));
  job = await fetch(`https://api.pdfloft.com/v1/jobs/${job.id}`, { headers }).then((r) => r.json());
}
if (job.status !== 'succeeded') throw new Error(`The run ended as ${job.status}: ${job.error}`);

const link = await fetch(`https://api.pdfloft.com/v1/jobs/${job.id}/results/0`, { headers }).then((r) => r.json());
await writeFile('searchable.pdf', Buffer.from(await fetch(link.url).then((r) => r.arrayBuffer())));
Python
import os
import time
import uuid

import requests

headers = {"Authorization": f"Bearer {os.environ['PDFLOFT_API_KEY']}"}


def upload(path, content_type):
    with open(path, "rb") as handle:
        return requests.post("https://api.pdfloft.com/v1/files", headers={**headers, "Content-Type": content_type}, data=handle).json()


file0 = upload("scan.pdf", "application/pdf")

job = requests.post(
    "https://api.pdfloft.com/v1/tools/ocr/runs",
    headers={**headers, "Idempotency-Key": str(uuid.uuid4())},
    json={"files": [file0["id"]], "options": {"languages":["eng"]}},
).json()

while job["status"] in ("queued", "running"):
    time.sleep(1)
    job = requests.get(f"https://api.pdfloft.com/v1/jobs/{job['id']}", headers=headers).json()
if job["status"] != "succeeded":
    raise RuntimeError(f"The run ended as {job['status']}: {job['error']}")

link = requests.get(f"https://api.pdfloft.com/v1/jobs/{job['id']}/results/0", headers=headers).json()
open("searchable.pdf", "wb").write(requests.get(link["url"]).content)

Options

languages is required: one to three of eng, spa, hin, fra, deu, jpn, chi_sim, ara, por and heb. Name every language the document is written in; a language that is not named is read poorly. The result is the same document with the recognised words laid invisibly over each page, so they can be found, selected and copied. A page that already has text is left as it was.

A run takes one PDF. OCR can take longer than other tools on a long document, so follow the run with job.completed and job.failed webhooks instead of polling for many minutes.

If a call fails

  • options.invalid: languages is missing, is empty, names more than three languages or names a language that is not in the list.
  • run.too_many_files: OCR takes one PDF in a run.
  • file.type_refused: the file is not a PDF. Convert an image to a PDF first with jpg-to-pdf.
  • Every error code is listed on the errors page, and a run that failed carries its code in the error field of the run.