https://txtfetch.com/extract/scanned-pdf/
Scanned PDFs, OCR'd without a flag.
No text layer, skewed feeder scans, low-DPI faxes. txtfetch detects the blank pass itself and retries through OCR, in the same request.
the-problem
A scanned PDF has no text layer at all. It's a picture of a document wearing PDF packaging. A naive text-extraction pass on it reads back nothing but whitespace. Real scans add their own damage on top. There's skew and rotation from the sheet feeder. Low-DPI captures under ~200 dpi blur character edges past recognition. And fax-style CCITT Group 4 compression hits bitonal pages. The sharpest trap is the mixed document: a digitally-created cover page stapled ahead of a faxed, scanned body. A page-one text pass on that document comes back non-empty. So a system that only checks 'did we get any text' never learns that the rest of the file is a picture.
one-request-solution
txtfetch always tries the fast path first: a direct text-layer extraction. Only when the *entire* document comes back whitespace does it retry the whole file. That retry runs through Tika's ocr_only PDF strategy with Tesseract underneath. It's the same request and the same { status, extracted_text } shape, with no ocr=true flag to set. That escalation is deliberately whole-document. So the mixed cover-page-plus-scanned-body case above won't auto-escalate, because the first pass isn't blank. For those documents, or anywhere page-by-page fidelity matters more than the extra latency, pass ?quality=premium. Every page then routes through a vision-capable model instead of the text/OCR fork. So a digital cover page and a scanned body both get read consistently.
curl -X POST https://api.txtfetch.com/v1/extract \
-H "Authorization: Bearer $TXTFETCH_KEY" \
-F file=@faxed-inspection-report.pdfimport os
import requests
with open("faxed-inspection-report.pdf", "rb") as f:
r = requests.post(
"https://api.txtfetch.com/v1/extract",
headers={"Authorization": f"Bearer {os.environ['TXTFETCH_KEY']}"},
files={"file": f},
)
print(r.json()["extracted_text"])import { readFile } from "node:fs/promises";
const file = new Blob([await readFile("faxed-inspection-report.pdf")]);
const form = new FormData();
form.append("file", file, "faxed-inspection-report.pdf");
const res = await fetch("https://api.txtfetch.com/v1/extract", {
method: "POST",
headers: { Authorization: `Bearer ${process.env.TXTFETCH_KEY}` },
body: form,
});
const { extracted_text } = await res.json();
console.log(extracted_text);package main
import (
"bytes"
"encoding/json"
"fmt"
"io"
"mime/multipart"
"net/http"
"os"
)
type extractResponse struct {
Status string `json:"status"`
ExtractedText string `json:"extracted_text"`
}
func main() {
f, err := os.Open("faxed-inspection-report.pdf")
if err != nil {
panic(err)
}
defer f.Close()
var body bytes.Buffer
writer := multipart.NewWriter(&body)
part, err := writer.CreateFormFile("file", "faxed-inspection-report.pdf")
if err != nil {
panic(err)
}
if _, err := io.Copy(part, f); err != nil {
panic(err)
}
writer.Close()
req, err := http.NewRequest("POST", "https://api.txtfetch.com/v1/extract", &body)
if err != nil {
panic(err)
}
req.Header.Set("Authorization", "Bearer "+os.Getenv("TXTFETCH_KEY"))
req.Header.Set("Content-Type", writer.FormDataContentType())
resp, err := http.DefaultClient.Do(req)
if err != nil {
panic(err)
}
defer resp.Body.Close()
var result extractResponse
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
panic(err)
}
fmt.Println(result.ExtractedText)
}Or skip the download. Pass a url parameter and txtfetch fetches the document server-side:
curl -X POST "https://api.txtfetch.com/v1/extract?url=https://example.com/scans/site-survey.pdf" \
-H "Authorization: Bearer $TXTFETCH_KEY"import os
import requests
r = requests.post(
"https://api.txtfetch.com/v1/extract",
headers={"Authorization": f"Bearer {os.environ['TXTFETCH_KEY']}"},
params={"url": "https://example.com/scans/site-survey.pdf"},
)
print(r.json()["extracted_text"])const endpoint = new URL("https://api.txtfetch.com/v1/extract");
endpoint.searchParams.set("url", "https://example.com/scans/site-survey.pdf");
const res = await fetch(endpoint, {
method: "POST",
headers: { Authorization: `Bearer ${process.env.TXTFETCH_KEY}` },
});
const { extracted_text } = await res.json();
console.log(extracted_text);package main
import (
"encoding/json"
"fmt"
"net/http"
"net/url"
"os"
)
type extractResponse struct {
Status string `json:"status"`
ExtractedText string `json:"extracted_text"`
}
func main() {
endpoint, err := url.Parse("https://api.txtfetch.com/v1/extract")
if err != nil {
panic(err)
}
q := endpoint.Query()
q.Set("url", "https://example.com/scans/site-survey.pdf")
endpoint.RawQuery = q.Encode()
req, err := http.NewRequest("POST", endpoint.String(), nil)
if err != nil {
panic(err)
}
req.Header.Set("Authorization", "Bearer "+os.Getenv("TXTFETCH_KEY"))
resp, err := http.DefaultClient.Do(req)
if err != nil {
panic(err)
}
defer resp.Body.Close()
var result extractResponse
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
panic(err)
}
fmt.Println(result.ExtractedText)
}{
"status": "success",
"extracted_text": "..."
}what-comes-back
That is the shape. This is the text. A real .pdf from the benchmark corpus, word-diffed against the raw parser output. Nothing here was written for this page. Every character comes from a committed fixture.
Scanned Invoice #9042 (image-only PDF)
raw parser output
lnvoice #9O42
Bill to: Northwind Tradcrs, 44 Harbor R0w
ltem Qty Unit Pricc Total Onsite installatlon 2 $45O.OO $9OO.OO Annual support pian 1 $1,2OO.OO $1,2OO.OO
Tota1 due: $2,1OO.OO
what txtfetch returns
# Invoice #9042
Bill to: Northwind Traders, 44 Harbor Row
| Item | Qty | Unit Price | Total |
| --- | --- | --- | --- |
| Onsite installation | 2 | $450.00 | $900.00 |
| Annual support plan | 1 | $1,200.00 | $1,200.00 |
Total due: $2,100.00
See all ten documents, with the full explanation →
formats-covered
.pdf
response-options
Mixed text-and-scan PDF? ?quality=premium reads every page through a vision model instead of the whole-document text/OCR fork. A digital cover page and a scanned body both come back accurately.
faq
- Why is my scanned PDF coming back empty or blank?
- If Tesseract still finds no readable text after the automatic OCR retry, the request returns an extraction_failed error rather than guessing. That can happen with an extremely low-resolution scan, a blank page, or a corrupted image stream. Try ?quality=premium for a second read via a vision model.
- Do I need to tell txtfetch a PDF is scanned?
- No, there's no ocr=true parameter for PDFs. txtfetch runs a text pass first. It only escalates to OCR automatically when that pass comes back whitespace across the whole document.
- What about a PDF with a digital cover page and scanned pages after it?
- That's the one case whole-document escalation misses. The first pass isn't blank, so OCR never kicks in for the rest. Pass ?quality=premium for these mixed documents. It reads every page through a vision model instead of relying on the text-or-OCR fork.
- Does OCR handle skewed or low-quality fax scans?
- Tesseract corrects moderate skew and reads CCITT Group 4 fax-compressed bitonal pages. Accuracy drops, though, on very low-DPI (under ~150 dpi) source scans. See /benchmarks for measured accuracy by document category.
go-further
- Read the .pdf guide →
- Digital-native PDFs and the text-layer fast path →
- See exactly which of your PDF's pages have real text — free, page by page →
- Multi-page TIFF faxes need a different read →
- Measured OCR accuracy by document category →
- Is your PDF scanned, mixed, or text — find out free →
- Not sure a scan will OCR cleanly? Check it free, in your browser →
- API quickstart →
- How the pipeline works →
- Extract it from your language →
- Get an API key →
PDF & scans
Send a real .pdf (scanned) through it.
One HTTP call returns the text. Read one in your browser first, for free.
Get an API key →