Does your PDF actually contain text?

Drop it below and find out — text layer, scan, mixed, or encrypted — before you burn a request finding out the hard way. Runs entirely in your browser; nothing is uploaded.

Drop a PDF below to scan its structure — runs in your browser, nothing is uploaded.

what-this-scan-looks-at

This is a best-effort structural scan, not a full PDF parser. It walks the file's raw bytes for streamendstream pairs, decodes whatever is Flate-compressed (or ASCII85/ASCIIHex-armoured over it) using the browser's own DecompressionStream, and looks for a handful of tokens in the result: embedded fonts, BT/Tj/TJ text-painting operators, image objects, an /Encrypt dictionary, and a few others documented in the report itself. It also reads each page object's own /Contents stream — following the placed graphics it draws, and resolving each one through the page's /Resources to tell a scanned image apart from vector art — so it can say which pages paint text rather than only whether the file does anywhere. That per-page reading is what makes the mixed-document verdict below possible. It never builds a real object graph or resolves a cross-reference table the way Apache Tika (what txtfetch's API actually runs) does — so when the signals disagree, it says inconclusive rather than guess. That bias is deliberate: a confidently wrong verdict on your actual file is worse than an honest "couldn't tell."

the-three-kinds-of-pdf

Nearly every PDF extraction problem traces back to which of three shapes a given file actually is:

  • Born-digital. Produced by a word processor, browser print-to-PDF, or typesetting system — text is stored as text, with real character codes and a font to render them. Any text extractor reads this cleanly.
  • Image-only (a scan). Every page is a picture — a photographed page, a faxed document, a flatbed scan saved straight to PDF. There is no text to read at all; the only way in is OCR.
  • Mixed / hybrid. Some pages are born-digital, others are scanned images — most often a digital cover page or form ahead of a scanned body. This is the shape that breaks naive "did we get any text back" checks, described below.

why-your-library-returns-nothing

If pypdf, pdfminer.six, or pdftotext hands you back an empty string, the file is almost always image-only — there is no text layer for any library to find, so an empty result is the correct answer to the wrong question. The fix isn't a better PDF library; it's OCR. See how scanned PDFs get OCR'd automatically, the OCR-through-one-API-call writeup, or the Python extraction guide if pdf-parsing libraries are what you're replacing.

the-tounicode-problem

A born-digital PDF can still extract as mojibake. A /ToUnicode CMap is the table that records which actual Unicode character each glyph represents; without one, an extractor may have nothing to translate the page's character codes back into. The page renders perfectly either way — the glyph shapes are all present — which is why this problem is invisible until you try to read the text out.

The important nuance, and the reason this scan is careful about what it claims: a missing /ToUnicode is only a problem for some fonts. An ordinary simple font (/Type1 or /TrueType) without one is usually fine, because its encoding — a standard name like /WinAnsiEncoding, a table of standard glyph names, or the built-in StandardEncoding of the base-14 fonts — already establishes what each code means. The case that genuinely breaks is a composite /Type0 font using an /Identity-H encoding with no /ToUnicode: Identity-H maps codes straight to glyph numbers inside the embedded font program, so there is provably no route back to Unicode. That's the only font shape this tool will warn you about, because it's the only one it can be certain about from structure alone. There's no fix on the reading side — it has to be corrected in whatever produced the file, or worked around with OCR on the affected pages. See measured extraction accuracy by document category for how much this costs in practice.

the-mixed-document-trap

This is the case this tool exists to catch. txtfetch's automatic OCR escalation is deliberately whole-document: it only retries through Tesseract when the entire first pass comes back blank. A digital cover page ahead of a scanned body means the first pass isn't blank, so the scanned pages behind it never trigger OCR — and a system that only checks "did we get text back" never learns anything went wrong. Pass ?quality=premium for these documents: every page routes through a vision-capable model instead of the text/OCR fork, so a digital cover page and a scanned body both come back accurately. Read more on the scanned-PDF page.

The scan finds it by asking the question per page: does this page paint text, or does it only draw a scanned image? A whole-document count can't answer that — real producers emit several BT blocks per page of text, so "fewer text blocks than pages" is never true for an ordinary file, and "an image on every page" is just as true of an illustrated report as of a scan. Nor is "the page draws something" enough: placed vector art (a figure from LaTeX \includepdf, or Illustrator art placed in InDesign) is drawn exactly like a page scan is, so the scan resolves what's actually being drawn and counts a page as scan-like only when it resolves to a real image. Text that lives inside placed graphics is followed too, and counts for the page that places it. What the scan still can't tell you is why a page carries no text at all, which is why the verdict says possibly mixed and the report prints the page counts behind it.

The same trap has a second shape, and it's the one that looks healthiest: a scan with a stamp on every page. Bates numbering across a discovery set, a copier's date-and-filename line, an ACME CORP — CONFIDENTIAL header added over a scanned batch — each page now contains real, extractable text, so nothing about the file is empty and OCR escalation never fires, but everything a reader actually wants is still pixels. A plain request returns the stamps and drops the document. The scan above weighs how much text there is against how much of each page is covered by an image, and says so when the answer is "a stamp's worth" — it can't know whether that text is your page body or a rubber stamp, so it tells you what it measured and leaves the call to you.

encrypted-pdfs

An /Encrypt dictionary in the file means the API will reject it with a typed encrypted error (HTTP 422) unless it opens without a password — see the full error reference. What this scan genuinely can't tell you is which kind of encrypted PDF you have: some are protected with a real user password, and some are only owner-password-protected with an empty user password, which Tika opens without issue. There's no way to distinguish the two from outside the file — the only way to know is to try the real request.

what-to-do-next

Whatever the scan above found, the call is the same shape either way:

curl
curl -X POST https://api.txtfetch.com/v1/extract \
  -H "Authorization: Bearer $TXTFETCH_KEY" \
  -F file=@document.pdf
Python
import os
import requests

with open("document.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("document.pdf")]);
const form = new FormData();
form.append("file", file, "document.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("document.pdf")
	if err != nil {
		panic(err)
	}
	defer f.Close()

	var body bytes.Buffer
	writer := multipart.NewWriter(&body)
	part, err := writer.CreateFormFile("file", "document.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)
}

faq

Why does my PDF come back empty when I try to extract text from it?
Almost always because there's no text layer to read — the PDF is a picture of a document, not a document. A scanned invoice, a faxed contract, or a photographed page all produce a PDF with no BT/Tj text operators inside it, so pdftotext, pypdf, pdfminer, and similar libraries correctly read nothing back — it's not a bug in them, there's genuinely no text there. Drop the file above to check, or see why scanned PDFs need OCR.
How can I tell if a PDF has a real text layer or is a scan?
Open it and try to select a word with your cursor — if nothing highlights, there's no text layer. This tool does the same check structurally: it looks for embedded fonts and BT…Tj text-painting operators in the PDF's content streams. Fonts and text operators both present means a real text layer; images with no text operators at all means a scan.
What is a 'mixed' PDF, and why is it worse than a fully-scanned one?
A mixed PDF has some pages with a real text layer and others that are scanned images — the classic shape is a digitally-created cover page stapled ahead of a faxed, scanned body. It's worse than a fully-scanned PDF because automatic OCR escalation (in txtfetch and most other extractors) only fires when the entire document comes back blank on the first pass. Since page one isn't blank, the scanned pages behind it silently never get OCR'd. Pass ?quality=premium to read every page through a vision model instead.
Why does my extracted text come out garbled, or full of boxes and question marks?
The usual cause is a missing /ToUnicode CMap — the table recording which actual Unicode character each glyph represents. The page still renders correctly, because the glyph shapes are all there; only reading the text back out breaks. It matters most for composite (/Type0) fonts using an /Identity-H encoding, which map character codes straight to glyph numbers inside the embedded font program, leaving no route back to Unicode at all. Ordinary /Type1 and /TrueType fonts without a /ToUnicode map are usually fine, because their encoding already establishes what each code means. There's no general fix on the reading side; it has to be corrected in whatever produced the PDF, or worked around with OCR on the affected pages instead of text extraction.
Does this checker upload my PDF anywhere?
No. The scan runs entirely in your browser using the Web Platform's own DecompressionStream API — your file is read locally and never sent over the network. The only thing that leaves your browser is the finished report, if you choose to copy it.
Can this tool tell me if my PDF is encrypted?
It can tell you the PDF has an /Encrypt dictionary, which means the API will ask for a password one way or another. What it can't tell you — and no in-browser scan can — is whether that's a real user password (which txtfetch's API rejects with a 422 encrypted error) or an owner-password-only PDF with an empty user password, which opens and extracts just fine. The only way to know for certain is to try the real request.
What does an 'inconclusive' result mean?
It means the scan's structural signals didn't clearly match a known pattern — an unusual PDF producer, heavily compressed content this scan's size or complexity caps cut short, or a genuinely ambiguous structure. This tool is built to say "couldn't tell" rather than guess and be confidently wrong, so an inconclusive result is honest, not broken. Try the real API call to find out for certain.

Stop parsing. Start shipping.

Create an account and get an API key in minutes — the free Hobby plan needs no card.

Get started →