Scanned PDFs, OCR'd without a flag.

No text layer, skewed feeder scans, low-DPI faxes. txtfetch reads a page with OCR when it has almost no typed text, 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 reads the text layer first. Then it decides page by page. A page with 10 typed characters or fewer is read with OCR (Tesseract, through Tika). A page with more is not. The request and the { status, extracted_text } shape stay the same. There is no ocr=true flag to set. So a digital cover page and a scanned body both come back. One case fails. A scanned page that also carries a typed footer or stamp of more than 10 characters comes back as that typed text only. The scan is lost. Render that page to a PNG and send the PNG. Read /docs/images for the tested cases.

curl
curl -X POST https://api.txtfetch.com/v1/extract \
  -H "Authorization: Bearer $TXTFETCH_KEY" \
  -F file=@faxed-inspection-report.pdf
Python
import 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"])
JavaScript
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);
Go
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
curl -X POST "https://api.txtfetch.com/v1/extract?url=https://example.com/scans/site-survey.pdf" \
  -H "Authorization: Bearer $TXTFETCH_KEY"
Python
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"])
JavaScript
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);
Go
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)

invoice-9042-scan.pdf · application/pdf · left pane read via Apache Tika, right pane at premium (VLM)

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 →

try-your-own

Drop your own .pdf file below. Your browser reads it. Nothing uploads, and txtfetch never sees it.

Your browser can't run OCR. Drop a file below and this panel reports whether the page has a text layer instead. The API OCRs the scan and returns real text.

See the real API response in the playground. Need many files, a folder, or a .zip? Use file-to-text instead.

formats-covered

  • .pdf

response-options

Mixed text-and-scan PDF? Each scanned page is read on its own. A scanned page with more than 10 typed characters on it is not. Find those pages with the PDF text check.

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. Render the page to a PNG and send it again. A picture file always goes through OCR.
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. Then it reads each page with OCR if it has 10 typed characters or fewer. The page decides, not the file.
What about a PDF with a digital cover page and scanned pages after it?
Both come back. OCR runs on each scanned page, so the cover and the body are in the text. The risk is a scanned page with a typed footer or stamp of more than 10 characters. That page loses its scan. Read /docs/images for the cases we tested.
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

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 →

Open the free .pdf (scanned) reader →