https://txtfetch.com/tools/pdf-text-check/
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. It runs entirely in your browser. Nothing is uploaded.
Drop a PDF below to scan its structure. It runs in your browser, and nothing is uploaded.
Up to 40 MB scanned in full — larger files get a partial (clearly labeled) scan.
curl -X POST https://api.txtfetch.com/v1/extract \
-H "Authorization: Bearer $TXTFETCH_KEY" \
-F file=@__FILENAME__import os
import requests
with open("__FILENAME__", "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("__FILENAME__")]);
const form = new FormData();
form.append("file", file, "__FILENAME__");
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("__FILENAME__")
if err != nil {
panic(err)
}
defer f.Close()
var body bytes.Buffer
writer := multipart.NewWriter(&body)
part, err := writer.CreateFormFile("file", "__FILENAME__")
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)
}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 stream…endstream pairs. It decodes whatever is Flate-compressed, or ASCII85/ASCIIHex-armoured over it, using the browser's own DecompressionStream. Then it looks for a handful of tokens in the result. Those include 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. It follows the placed graphics it draws and resolves each one through the page's /Resources to tell a scanned image apart from vector art. That way it can say which pages paint text, not 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. Apache Tika, which is what txtfetch's API actually runs, does both. 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, or 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, and 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. That's why this problem is invisible until you try to read the text out.
There's an important nuance here, and it's why 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. Its encoding already establishes what each code means. That encoding might be a standard name like /WinAnsiEncoding, a table of standard glyph names, or the built-in StandardEncoding of the base-14 fonts. 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. 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 decides OCR page by page. A page with 10 typed characters or fewer is read with Tesseract. A scanned page that also carries more than 10 typed characters comes back as that typed text only. A footer, a page number line or a stamp is enough. The scan under it is lost, and nothing warns you. A system that only checks "did we get text back" never learns anything went wrong. Render those pages to PNG files and send the PNG files. A picture file is always read. Read more on text in pictures and scans and 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. It counts a page as scan-like only when that 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. That's 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. Think of Bates numbering across a discovery set, a copier's date-and-filename line, or 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 does not run on a page with more than 10 typed characters. 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. It 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). We tested this on 2026-10-22. The API also refuses a PDF that has only an owner password, and that PDF opens in a viewer with no password. See the full error reference. This scan cannot tell you which kind of encrypted PDF you have. Both kinds need the same fix: remove the encryption, then upload. Password-protected files shows how, and shows what the API returns for other locked files.
what-to-do-next
Got real text back but it's garbled rather than empty? See why PDF text extracts as gibberish and why a PDF comes back empty for the two most common causes this scan surfaces. Otherwise, whatever the scan above found, the call is the same shape either way:
curl -X POST https://api.txtfetch.com/v1/extract \
-H "Authorization: Bearer $TXTFETCH_KEY" \
-F file=@document.pdfimport 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"])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);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)
}Once you have extracted_text back, the chunk previewer shows how that text will actually chunk. It shows token estimates, overlap, and the extraction-damage signals that wreck chunk boundaries, for fixed, recursive, and structure-aware chunking.
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. That'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. txtfetch reads each page on its own, so a cover page and a scanned body both come back. The risk is a scanned page that also carries a typed footer, page number or stamp of more than 10 characters. That page comes back as its typed text only, and the scan is lost. Render it to a PNG and send the PNG. Read /docs/images for the tested cases.
- Why does my extracted text come out garbled, or full of boxes and question marks?
- The usual cause is a missing /ToUnicode CMap. That's 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. Those 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. The API refuses every PDF that has one, with a 422 encrypted error. That includes a PDF that opens in a viewer with no password, because only its owner password is set. A scan cannot tell the two kinds apart. To get the text, remove the encryption first. See the unlock steps on the password-protected files page.
- What does an 'inconclusive' result mean?
- It means the scan's structural signals didn't clearly match a known pattern. Maybe an unusual PDF producer, content cut short by this scan's size or complexity caps, 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.
That was one file. The API does the queue.
This page read your PDF on your own machine. The API reads a folder of them.
Read the quickstart →