{"slug": "scanned-pdfs-vs-text-pdfs-parsing-accuracy-and-cost", "title": "Scanned PDFs vs text PDFs: Parsing accuracy and cost", "summary": "Spidra reported that on 26 October 2026 it parsed 84 pages in all three of its Parse modes and found scanned pages cost the same 1 credit each as text pages in auto and OCR mode but took about 2 seconds of Parse time per page versus 0.09 seconds for text pages. On the 84 synthetic English pages, auto mode averaged 0.0% omitted words at 300, 200 and 150 DPI but lost 2.8% at 100 DPI, including a financial table whose thousands separators all returned as decimal points, while fast mode refused every scan for free. Hidden text layers leaked into output: a faulty layer's errors came out word for word on one page, and a stamp-only layer's text appeared in 2 of 12 auto outputs and none of the OCR ones.", "body_md": "Somewhere in your document pile there's a folder of scans, and you don't yet know whether it's a rounding error or half your volume. Before you build an ingestion pipeline around it, there are two questions: will Parse read those pages accurately, and what will they cost next to the text PDFs sitting beside them?\n\nWe ran an experiment to find out. On 26 October 2026, we parsed 84 pages in all three Parse modes, every one with a right answer known in advance. Every number here is for those pages on that day. The method is the part you can reuse. If you only need to parse one PDF, [our single-document guide](https://spidra.io/blog/how-to-scrape-pdf-extract-json-api) is the shorter path, and the [Parse docs](https://docs.spidra.io/features/parse) list every option.\n\n## What we found, in three sentences\n\nScans cost the same as text pages, 1 credit each in auto and OCR mode, but they took about 2 seconds of Parse time per page instead of **0.09**, because AI reads them. On our 84 synthetic English pages, auto mode averaged 0.0% omitted words at 300, 200 and 150 DPI, then lost **2.8%** at 100 DPI, including a financial table whose thousands separators all came back as decimal points. Fast mode refused every scan for free, and hidden text layers didn't fool auto the way we expected but did leak into the output: a faulty layer's errors came out word for word on one page, and a stamp-only layer's text appeared in 2 of 12 auto outputs and none of the OCR ones.\n\n## A scanned PDF is not one thing\n\nA PDF page can hold its text in three ways. A born-digital page carries its own text, so a parser can read the characters straight out of the file. A scanned page is a picture of text with nothing to read, so the words have to be recognised from pixels. And a searchable scan is both: a picture, plus a hidden text layer that a tool added afterwards. [OCRmyPDF](https://github.com/ocrmypdf/OCRmyPDF), for one, exists to add exactly that layer to scans.\n\nThat third kind is the awkward one, because Parse's modes are built around the first two. According to the docs, `auto` reads each page's own text and uses AI only for scanned pages. `fast` never uses AI: a scanned page comes back with an error and isn't charged. And `ocr` uses AI on every page. Parse charges 1 credit per page, scans included.\n\nThat's a prediction we can test. Text pages should cost 1 credit in all three modes. Scans should cost 1 in `auto` and `ocr` and nothing in `fast`. And the searchable scans are the open question: the docs say `auto` reads a page's own text when it has one, and a searchable scan has some. If the layer is wrong, or nearly empty, what then?\n\n## A corpus where the right answer is known\n\nSix page types: plain prose, a page of dense small print, an invoice, two columns, headings with bullet lists, and a financial table. Each one exists as a born-digital PDF and again as scans of falling quality. [Tesseract's documentation](https://github.com/tesseract-ocr/tessdoc/blob/main/ImproveQuality.md) says Tesseract works best at 300 DPI or higher, and has sections on noise and skew (we checked it in October 2026), so that's our ladder: 300 DPI clean, 200 DPI with a slight skew and some noise, 150 DPI skewed, noisy and blurred, and 100 DPI with all three at their worst. The code that builds it is near the end.\n\nTwo more versions look just like the 150 DPI scan, but aren't. In the first, the scan carries an invisible text layer with the kind of errors a bad OCR pass leaves behind (`l` for `1`, `m` for `rn`). In the second, the only text on the page is a one-line stamp. Both can happen when a scan passes through other tools: an OCR pass that gets things wrong, or a stamping tool that adds a line of text.\n\nThe script writes the reference text as it draws each page, so the answer key is exact. We checked it anyway, against the text layer of every born-digital page, and all 2,331 reference words were found (100.00%). Here is the same dense page in every version, so you can see what \"scan quality\" means here:\n\nOne limit belongs here and not at the end. The pages are synthetic, English and typeset. Handwriting, stains, other scripts and photographed pages are not in the corpus, so this experiment says nothing about them. Each page was also parsed once per mode, so a gap between two modes on one page can be the mode or can be run-to-run variation. Where it matters below, we say so.\n\n## Four numbers per page\n\nOmitted words is the share of the reference's words that never appear in the output, in any order. Extra words is the share of output words that aren't in the reference, which is where invented text would show up. Numbers kept is the share of the reference's numbers that appear exactly. And CER, the character error rate from [`jiwer`](https://pypi.org/project/jiwer/), which does punish reading order.\n\nOmission leads for a reason. A table or a two-column page read in a different order can score a terrible CER while every word is present, and a missing word is the failure that's hardest to notice later. We compare words, not formatting, so Markdown marks, table alignment rows, backslash separators and the punctuation around a word are ignored: `security.` and `security` match, but `138,525` and `138.525` don't. We had to learn that list the hard way, and we say how near the end.\n\n## The whole experiment, on one scanned page\n\nHere it is in miniature, on a single 150 DPI scan from the corpus (swap in any scanned PDF of your own). The file is uploaded once, then parsed in each mode by its file ID:\n\n``` python\n# modes.py\nimport os\nfrom spidra import Spidra, SpidraError\n\nclient = Spidra(api_key=os.environ[\"SPIDRA_API_KEY\"])\nfile_id = client.estimate_parse(\"corpus/pdf/scan150/body-1.pdf\").file_id  # uploads once, free\n\nprint(\"mode | pages | credits | read by | error\")\nfor mode in (\"auto\", \"fast\", \"ocr\"):\n    try:\n        doc = client.parse(file_id=file_id, mode=mode, include_pages=True)\n        page = doc.pages[0]\n        print(mode, doc.pages_parsed, doc.credits_used, page.method, page.error or \"ok\", sep=\" | \")\n    except SpidraError as e:\n        print(mode, \"failed:\", e)\n```\n\nOutput:\n\n```\nmode | pages | credits | read by | error\nauto | 1 | 1 | ocr | ok\nfast failed: No page of this document could be read.\nocr | 1 | 1 | ocr | ok\n```\n\nTwo modes read the scan with AI for 1 credit each. The third didn't fail one page: it failed the whole job, because a one-page scan leaves nothing it can read, and it charged nothing.\n\nThe experiment runs this for all 85 files. Each one is uploaded once and parsed three times in a random order, so no mode is systematically favoured by the time of day or a warm connection. (Uploading once and reusing the [file ID](https://spidra.io/blog/pdf-url-vs-upload-vs-file-id) is also what keeps 255 parses from being 255 uploads.)\n\n## Text PDFs: the same page in all three modes\n\n| Mode | Omitted words | Extra words | Numbers kept | CER | Credits per page | Parse seconds | Wall seconds | \n|---|---|---|---|---|---|---|---|\n| `auto` | 0.0% | 0.0% | 100.0% | 0.0% | 1.00 | 0.09 | 0.9 | \n| `fast` | 0.0% | 0.0% | 100.0% | 0.0% | 1.00 | 0.09 | 0.8 | \n| `ocr` | 0.0% | 0.3% | 100.0% | 0.4% | 1.00 | 1.98 | 2.6 | \n\nAll three modes read all 12 text pages without dropping a word. `ocr` mode cost the same: 1 credit per page, but it reported `ocr` as the method for all 12 pages, so every page was read with AI, and that took about 22 times as long: 1.98 seconds of Parse time against 0.09. It also added words to one page. On a text invoice, the output grew a table header row, `Description | Amount`, that isn't anywhere on the page. That's the only output of the 255 in which those words appear, and it accounts for the 0.3% of extra words. On these pages, `auto` did the same job in a fraction of the time.\n\n## Scans: where words start to go missing\n\n| Scan quality | AUTO: omitted | AUTO: numbers kept | OCR: omitted | OCR: numbers kept | Pages over 5% omitted (AUTO/OCR) | FAST | \n|---|---|---|---|---|---|---|\n| 300 DPI | 0.0% | 100.0% | 0.0% | 100.0% | 0 / 0 | All 12 failed, 0 credits | \n| 200 DPI | 0.0% | 100.0% | 0.0% | 100.0% | 0 / 0 | All 12 failed, 0 credits | \n| 150 DPI | 0.0% | 100.0% | 0.3% | 100.0% | 0 / 0 | All 12 failed, 0 credits | \n| 100 DPI | 2.8% | 93.5% | 1.3% | 99.5% | 2 / 2 | All 12 failed, 0 credits | \n\nThe tall bars are `fast`, which fails every scan, and are the only reason the scale goes to 100. Look at the other two: `auto` and `ocr` are flat until 100 DPI. Above that, the only non-zero page-type cells were small: `auto` on the 300 DPI prose pages (0.3%) and `ocr` on the two-column pages at 150 DPI (1.8%). The losses at 100 DPI are concentrated in two page types:\n\n| Page type | AUTO: at 100 DPI | OCR: at 100 DPI | \n|---|---|---|\n| body | 0.0% | 0.0% | \n| dense | 3.5% | 6.4% | \n| fin | 13.0% | 0.9% | \n| invoice | 0.0% | 0.0% | \n| report | 0.0% | 0.0% | \n| twocol | 0.0% | 0.3% | \n\nSmall print is where 100 DPI hurts: one dense page lost 6.5% of its words in both modes, and the other lost 6.2% in `ocr` and only 0.6% in `auto`. The financial table is the one to remember. In `auto` mode, one table page lost 25.9% of its words, and every one of the 14 mismatched numbers was the same mistake: a thousands separator read as a decimal point. `138,525` came out as `138.525`. Nothing was missing, but each of those numbers was wrong by a factor of 1,000, and only 22 of that page's 36 numbers were right (61%). The same page in `ocr` mode got 35 of 36 right (97%), with one digit misread.\n\nBefore you read that as `ocr` beating `auto` at 100 DPI, look at the other pages. The two modes disagreed on three of 12 pages, and the disagreements went both ways: `auto` was better on two pages, a dense one and a two-column one, and `ocr` was better on the table. Both read scans with AI, we parsed each page once per mode, and 12 pages can't separate a mode from run-to-run variation.\n\nWhat `fast` did is simpler and fully consistent. All 72 scan-like pages failed the same way, in about 0.7 seconds on average, for no credits:\n\n```\nSpidraJobFailedError: No page of this document could be read.\n```\n\nIt's a failed job, not a failed page. A page inside a document that has other readable pages is different, as the mixed document below shows.\n\n## Hidden text layers leak into scanned output\n\nHere is where our prediction broke. We expected `auto` to trust a page's text layer, faulty or not. It didn't. `auto` read all 24 of the trap pages with AI, reported `ocr` as the method for every one, and took about 2 seconds per page, the same as a plain scan. `fast`, which never uses AI, refused all 24 with the same message as a scan. As far as Parse is concerned, a hidden layer didn't turn these pages into text pages.\n\nThe layers still got into the output, in two different ways.\n\nThe faulty layer reached the output on one page of 12, in both modes. On the dense small-print page, 16.2% of the words came out as the layer's errors, character for character: `Regi0n`, `5hipment`, `week1y`, `standord`. All 54 of the words that exist only in the faulty layer appeared in the output of that page. The same image with no layer, parsed the same way, produced none of them. The other 11 faulty pages were untouched, which is why the average looks small (1.3% omitted) and the single-page result doesn't.\n\nThe stamp reached the output in `auto` and not in `ocr`. The stamp is invisible in the image, so the only place it exists is the text layer. On two of the 12 stamp pages, `auto` appended it after the page's text. `ocr` never did:\n\n```\nauto: ...support recovery customer recovery update.\n\nCONFIDENTIAL - Page 1 of 1\nocr:  ...support recovery customer recovery update.\n```\n\nThat's a mode-dependent failure, and a mild one: nothing is missing; there's a line too many. But a page read as an image can still carry its hidden layer into your output, so check for it if your documents pass through stamping or OCR tools. We can't tell you from outside why it happened on two pages and not ten.\n\nThe mixed document, one text page between a scan and another text page, behaved as the docs say:\n\n| Page | AUTO: read by, omitted | FAST: read by, omitted | OCR: read by, omitted | \n|---|---|---|---|\n| 1 (text) | text, 0.0% | text, 0.0% | ocr, 0.0% | \n| 2 (scan) | ocr, 0.0% | page error, 100.0% | ocr, 0.0% | \n| 3 (text) | text, 0.0% | text, 0.0% | ocr, 0.0% | \n| Credits charged | 3 | 2 | 3 | \n\n`auto` switched method page by page and charged 3 credits. `fast` read the two text pages, flagged the scan with a page-level error (\"This page is a scan or image. Use auto or OCR mode to read it.\"), and charged 2.\n\n## What it cost, in credits and seconds\n\n| Pages | Mode | Credits per page | Parse seconds | Wall seconds | \n|---|---|---|---|---|\n| Text PDF | `auto` | 1.00 | 0.09 | 0.9 | \n| Text PDF | `fast` | 1.00 | 0.09 | 0.8 | \n| Text PDF | `ocr` | 1.00 | 1.98 | 2.6 | \n| Scans, 300 to 100 DPI | `auto` | 1.00 | 1.88 to 2.10 | 2.5 to 2.8 | \n| Scans, 300 to 100 DPI | `fast` | 0.00 | 0.00 | 0.7 to 0.8 | \n| Scans, 300 to 100 DPI | `ocr` | 1.00 | 1.97 to 2.33 | 2.7 to 3.0 | \n| Scans with a hidden text layer | `auto` | 1.00 | 1.87 to 2.09 | 2.6 to 2.8 | \n| Scans with a hidden text layer | `fast` | 0.00 | 0.00 | 0.7 to 0.8 | \n| Scans with a hidden text layer | `ocr` | 1.00 | 1.80 to 1.96 | 2.5 to 2.7 | \n\nThe prediction held on cost: 1 credit a page in `auto` and `ocr`, text or scan, and `fast` charged only for pages it could read. The whole run, 255 parses, used 188 credits: 87 in `auto`, 87 in `ocr` and 14 in `fast`.\n\nThe difference you pay for is time. On our connection, a text page took about 0.9 seconds start to finish and a scan about 2.7. Sequentially, 10,000 pages is about 2.5 hours of text and about 7.5 hours of scans, for the same 10,000 credits. Whether that matters depends on how many of your requests you can run at once, which we didn't test.\n\n## Which documents fit\n\n| Document | Mode we'd start with | What we measured | Where we'd stop trusting it | \n|---|---|---|---|\n| Born-digital text PDFs | `auto` | 0.0% omitted words, 1 credit, 0.09 s | Other languages and layouts, which we didn't test | \n| Clean scans, 150 DPI or better | `auto` | 0.0% omitted at 300, 200 and 150 DPI, 1 credit, about 2 s | Handwriting, stains and photographed pages, which we didn't test | \n| Poor scans, around 100 DPI | `auto` or`ocr` , then check the numbers | 1.3% to 2.8% omitted, 93.5% to 99.5% of numbers kept | Dense small print and any table of figures | \n| Folders that mix both | `auto` | Switched method page by page, 1 credit a page | None seen | \n| Anything with a hidden text layer | `auto` or`ocr` , then diff | Faulty layer leaked on 1 page of 12, stamp on 2 of 12 | Don't assume the layer is ignored | \n\nOn these pages, then: a clean scan costs what a text page costs and reads as cleanly, and what you give up is time. The risk sits below about 150 DPI, in numbers, and in hidden text layers.\n\n## The scans are made by code you can read\n\nThese excerpts are from our corpus script. They lean on its imports and helpers, so they won't run pasted on their own. First the ladder, and the function that applies it to a rendered page:\n\n```\n# (dpi, skew in degrees, noise sigma, blur radius, jpeg quality)\nLEVELS = {\n    \"scan300\": (300, 0.0, 0, 0.0, 90),\npython\ndef degrade(img, level, seed):\n    dpi, skew, noise, blur, quality = LEVELS[level]\n    rng = np.random.default_rng(seed)\n    if skew:\n        img = img.rotate(skew, resample=Image.BICUBIC, fillcolor=255)\n    if blur:\n        img = img.filter(ImageFilter.GaussianBlur(blur))\n    pixels = np.asarray(img, dtype=np.float32)\n    if noise:\n        pixels = pixels + rng.normal(0, noise, pixels.shape)\n    img = Image.fromarray(np.clip(pixels, 0, 255).astype(\"uint8\"))\n    buffer = io.BytesIO()\n    img.save(buffer, \"JPEG\", quality=quality, dpi=(dpi, dpi))\n    return buffer.getvalue()  # the JPEG itself, embedded as is, like a scanner's PDF\n```\n\nThen the scan itself, as an image-only PDF page. Given `layer_lines` it also gets the invisible text layer, which is how both trap versions are made. The first trap's errors come from a small confusion table:\n\n``` python\ndef make_scan(text_pdf, level, seed, out, layer_lines=None):\n    \"\"\"An image-only PDF page. With layer_lines, the page also gets an invisible text layer.\"\"\"\n    jpeg = ROOT / \"work\" / \"page.jpg\"\n    jpeg.write_bytes(degrade(render(text_pdf, LEVELS[level][0]), level, seed))\n    c = canvas.Canvas(str(out), pagesize=letter)\n    c.drawImage(str(jpeg), 0, 0, width=W, height=H)\n    if layer_lines:\n        text = c.beginText(36, H - 40)\n        text.setTextRenderMode(3)  # invisible\n        text.setFont(\"Helvetica\", 9)\n        for line in layer_lines:\n            text.textLine(line)\n        c.drawText(text)\n    c.save()\nCONFUSE = {\"l\": \"1\", \"O\": \"0\", \"o\": \"0\", \"e\": \"c\", \"m\": \"rn\", \"i\": \"l\", \"a\": \"o\", \"s\": \"5\"}\n\ndef ocr_like_errors(lines, rate, seed):\n    rng = random.Random(seed)\n\n    def swap(ch):\n        return CONFUSE[ch] if ch in CONFUSE and rng.random() < rate else ch\n\n    return [\"\".join(swap(ch) for ch in line) for line in lines]\n```\n\n## The scorer was fixed three times after the results came back\n\nWe tested the scorer on known mistakes before the run, and real output still exposed three flaws. Each was a formatting rule we'd missed, and each inflated a number:\n\n- **Table alignment rows.**`ocr` mode writes Markdown tables with alignment markers like`:---` , and we counted each as a word. On text pages in`ocr` mode, extra words fell from 2.1% to 0.3%.\n- **Backslash separators.** On one invoice, three outputs used`\\` between columns where others use`|` . We counted 11 extra words on a page where nothing was extra.\n- **Punctuation.** One two-column page at 100 DPI scored 7.0% omitted words when all 372 words were there: 26 sentences had lost their final period, and we counted`security.` and`security` as different words. After the fix, omitted words at 100 DPI fell from 3.5% to 2.8% in`auto` and from 1.4% to 1.3% in`ocr` .\n\nNone of the fixes touched numbers kept, credits or timings. Changing a scorer after seeing results is something to be suspicious of, so we kept every raw output and re-scored all 255 from the saved files. The fixes only make the scorer ignore formatting: the comma-to-period change that turned `138,525` into `138.525` is still counted as an error, and row ten of the test table proves it. These excerpts are from the scoring script, and the same caveat applies:\n\n``` python\ndef normalise(text):\n    \"\"\"Compare words, not formatting: drop Markdown marks, bullets and table rows.\"\"\"\n    text = re.sub(r\"<!--.*?-->\", \" \", text)\n    text = re.sub(r\"[|#*]\", \" \", text)\n    text = re.sub(r\"(?<!\\S)[-:\\\\\\u2022]+(?!\\S)\", \" \", text)\n    return re.sub(r\"\\s+\", \" \", text).strip()\n\ndef words(text):\n    \"\"\"Words without the punctuation around them, so 'security.' and 'security' match.\"\"\"\n    return [w for w in (t.strip(\".,;:!?()[]\\\"'\") for t in normalise(text).split()) if w]\npython\ndef metrics(reference, output):\n    ref, out = normalise(reference), normalise(output)\n    ref_words, out_words = words(reference), words(output)\n    matched = sum((Counter(ref_words) & Counter(out_words)).values())\n    numbers_ref = Counter(w for w in ref_words if any(ch.isdigit() for ch in w))\n    numbers_out = Counter(w for w in out_words if any(ch.isdigit() for ch in w))\n    kept = None\n    if numbers_ref:\n        kept = sum((numbers_ref & numbers_out).values()) / sum(numbers_ref.values())\n    return {\n        \"omission\": 1 - matched / len(ref_words),  # reference words that never appear\n        \"extra\": (len(out_words) - matched) / len(ref_words),  # words not in the reference\n        \"numbers\": kept,  # of the reference's numbers, the share that appear exactly\n        \"cer\": jiwer.cer(ref, out) if out else 1.0,  # sensitive to reading order, unlike omission\n    }\n```\n\nHere is the scorer fed known mistakes, using the invoice page's reference text. Rows seven to nine are the ones that would have caught our three flaws:\n\n| Output | Omitted | Extra | Numbers kept | CER | \n|---|---|---|---|---|\n| Identical to the reference | 0.0% | 0.0% | 100% | 0.0% | \n| 10% of the words deleted | 8.9% | 0.0% | 95% | 8.0% | \n| Same words, lines in reverse order | 0.0% | 0.0% | 100% | 68.1% | \n| Every digit changed | 39.3% | 39.3% | 0% | 22.5% | \n| Every word said twice | 0.0% | 100.0% | 100% | 100.3% | \n| Same words as a Markdown table | 0.0% | 0.0% | 100% | 0.0% | \n| Same words as an aligned table | 0.0% | 0.0% | 100% | 0.0% | \n| Same words, backslash as column separator | 0.0% | 0.0% | 100% | 0.0% | \n| Same words, sentence punctuation dropped | 0.0% | 0.0% | - | 1.0% | \n| A comma turned into a period in every number | 3.6% | 3.6% | 91% | 0.5% | \n| Nothing returned | 100.0% | 0.0% | 0% | 100.0% | \n\nReversed lines cost 68% CER and no omissions, which is the point of having both. A reformatted table costs nothing, and a changed number costs something.\n\n## Test it on your own scans\n\nTake a handful of pages you already know the answer to, from the stack you're worried about: a few born-digital, a few scanned at different quality, and one document that mixes them. Run each one through all three modes with the snippet above, uploading it once and parsing it by file ID, and score what comes back on the four numbers. The estimate is free and `fast` is a free test, so the first look costs very little.", "url": "https://wpnews.pro/news/scanned-pdfs-vs-text-pdfs-parsing-accuracy-and-cost", "canonical_source": "https://spidra.io/blog/scanned-vs-text-pdf-parsing", "published_at": "2026-10-27 00:00:00+00:00", "updated_at": "2026-10-27 14:11:08.594168+00:00", "lang": "en", "topics": ["ai-tools", "ai-products", "artificial-intelligence"], "entities": ["Spidra", "Parse", "OCRmyPDF", "Tesseract"], "also_reported_by": [], "alternates": {"html": "https://wpnews.pro/news/scanned-pdfs-vs-text-pdfs-parsing-accuracy-and-cost", "markdown": "https://wpnews.pro/news/scanned-pdfs-vs-text-pdfs-parsing-accuracy-and-cost.md", "text": "https://wpnews.pro/news/scanned-pdfs-vs-text-pdfs-parsing-accuracy-and-cost.txt", "jsonld": "https://wpnews.pro/news/scanned-pdfs-vs-text-pdfs-parsing-accuracy-and-cost.jsonld"}}