On this page
- What was compared: four PDF renderers and the market
- How to evaluate a PDF renderer for OCR: nine dimensions and why each counts
- PDF rendering speed: PNG encoding was the biggest cost
- Memory and parallel load: raw pixels trade memory for time
- Hostile PDFs: which renderer survived 29 cases
- What went wrong: Windows CPU seconds, a corrected number and a memory limit that does not protect
- Do the pixels match? Image quality of PDFium against Poppler
- Licence, security record and update effort
- What happens next: raw pixels first, native PDFium behind a gate
- Scope and limits of this measurement
- What the service does with this
- Sources
Your OCR service gets a PDF from a stranger. Before any model sees a word, something has to turn each page into pixels: fast enough to keep the GPU busy, safe against files built to break it, and under a licence you can ship. Which renderer does that best?
A measurement on 18 real pages and 29 hostile PDFs gives a first answer for this one machine. Encoding the page as PNG cost more than drawing it. Asking Poppler for raw pixels instead of PNG cut the median time per page from 397 ms to 158 ms at 150 dpi (round 1), with pixel-identical output on all 36 page and resolution pairs. Which means: the cheapest speed-up needed no new renderer at all.
This is one laptop under Windows, not a server benchmark. The machine had normal background load, so the runs are marked sauber: false in the raw data. Linux was not measured, and the OCR quality of the native PDFium variant is not checked yet. The last section lists every gap.
What was compared: four PDF renderers and the market
Four candidates went through the same harness. Each case ran in its own process, so a crash or hang in one could not touch the next.
| Candidate | How it runs | Licence | |
|---|---|---|---|
| A | Poppler pdftoppm, PNG output | child process, the intake before 10 Nov 2026 | GPL-2/3 |
| B | Poppler pdftoppm, raw PPM output | child process, same binary | GPL-2/3 |
| C | PDFium compiled to WebAssembly (go-pdfium v1.21.4 on wazero v1.12.0) | inside the Go process, no cgo | MIT, Apache-2.0, BSD-3 |
| D | native PDFium (pdfium-binaries chromium/8152) | small child process loading the library | Apache-2.0, BSD-3 |
The market was read, not measured. Five more candidates fell out on paper:
- MuPDF and a Go wrapper service built on it: AGPL, and the wrapper had no licence file.
- pdfcpu: parses PDFs but does not rasterise.
- Ghostscript (AGPL) and xpdf (GPL): not measured; licences not re-checked at the source.
- pdf.js: Apache-2.0, but needs Node or a browser with a canvas, which is more foreign code, not less.
- hayro (pure Rust, MIT/Apache-2.0): describes itself as far from complete, with no encrypted PDFs and gaps in blending.
This post publishes its own method and raw files so that anyone can rerun it. It does not claim that no other comparison exists: the market was read once, and apart from hayro not checked at the primary sources.
How to evaluate a PDF renderer for OCR: nine dimensions and why each counts
| Dimension | Why it counts for OCR | How it was measured |
|---|---|---|
| Time per page at 150 and 300 dpi | pages per hour per CPU core; small print can need more than 150 dpi (our API accepts 150, 200 and 300, the default is 150) | median over 18 pages, wall time of one core |
| Load with several pages at once | a service renders in parallel | 50 pages at 1, 2 and 4 in parallel, total time |
| Memory peak | one big page must not take the host down | peak of the job, median and maximum |
| Hostile PDFs | uploads are untrusted | 29 cases, 90 s wall limit, 3 GiB for the whole run |
| Pixel equality | different pixels can mean different OCR text | difference to A on all 18 pages |
| Isolation | a parser bug should kill a child, not the service | in process or child process |
| Licence | decides what ships to a customer | licence files and source offer |
| Security record and update effort | a renderer parses attacker input all day | public advisories, release cadence |
| Platforms and size | a self-hosted package carries the renderer or needs it installed | binary sizes, build needs |
CPU seconds are missing from this list on purpose: the Windows counter turned out to be unusable, as the section on what went wrong shows.
PDF rendering speed: PNG encoding was the biggest cost
All times are medians over the same 18 pages, wall time for one page, one process at a time. The test set holds digital pages, scans (one of them rotated), pages with text inside images, a photo and handwriting.
Median milliseconds per page over 18 pages. Round 1 and round 2 ran at different machine states and are never mixed in one panel (round 1 scale 0 to 1,500 ms, round 2 scale 0 to 1,000 ms). Grey: Poppler PNG. Blue: Poppler raw PPM. Orange: PDFium in Wasm. Teal: native PDFium.
| Variant | Round | 150 dpi | 300 dpi |
|---|---|---|---|
| A, Poppler PNG | 1 | 397 ms | 1,473 ms |
| B, Poppler raw PPM | 1 | 158 ms | 470 ms |
| C, PDFium in Wasm | 1 | 596 ms | 943 ms |
| A, Poppler PNG | 2 | 336 ms | 907 ms |
| B, Poppler raw PPM | 2 | 88 ms | 357 ms |
| D, native PDFium | 2 | 74 ms | 149 ms |
Three readings follow.
PNG was the cost. A real intake also has to decode the PNG again before OCR. That variant (A plus decode) took 432 ms at 150 dpi and 2,182 ms at 300 dpi in round 1. Raw output skips both steps. In round 1 it was 60 percent faster than A at 150 dpi, and 63 percent faster than A with decoding. The slowest page in the set, a photo with dense text at 300 dpi, took 10.6 s as PNG and 1.56 s as raw pixels.
Native PDFium is faster again, mostly at 300 dpi. Against B in round 2 it saved 16 percent at 150 dpi and 58 percent at 300 dpi. At 150 dpi the gain over raw Poppler is small.
WebAssembly paid a start-up price. Compiling the 5.8 MB module took 1.4 to 1.6 s per process, about 0.11 s with a compile cache. At 150 dpi it was also slower than the PNG route, at 300 dpi faster than PNG but slower than raw Poppler.
Round 2 re-measured A, B and the PNG decode on a quieter machine, and B came out at 88 ms instead of 158 ms. That gap is machine state alone, with the same binary and the same pages. It is the reason round 1 and round 2 numbers are compared only within one round.
Memory and parallel load: raw pixels trade memory for time
Raw pixels are large. A page as PPM is 2 to 52 MB, 3.8 to 255 times the size of the PNG. The peak memory of a job shows the price (medians over the 18 pages).
| Variant | Peak at 150 dpi | Peak at 300 dpi |
|---|---|---|
| A, Poppler PNG | 76 MB | 123 MB |
| B, Poppler raw PPM | 118 MB | 287 MB |
| C, PDFium in Wasm | 193 MB | 365 MB |
| D, native PDFium | 163 MB | 292 MB |
The largest single peaks were 482 and 489 MB for B and 455 MB for D at 300 dpi.
For load, 50 pages at 150 dpi were rendered with 1, 2 and 4 pages in parallel. Total seconds and job peak:
| Parallel | A | B | C | D |
|---|---|---|---|---|
| 1 (round 1) | 34.5 s, 85 MB | 11.3 s, 150 MB | 56.6 s, 363 MB | not run |
| 4 (round 1) | 15.1 s, 129 MB | 4.7 s, 198 MB | 20.0 s, 862 MB | not run |
| 1 (round 2) | 31.6 s, 84 MB | 12.0 s, 153 MB | not run | 8.6 s, 213 MB |
| 4 (round 2) | 14.4 s, 131 MB | 6.3 s, 199 MB | not run | 3.3 s, 469 MB |
No run had a single failure. The Wasm variant grew to 862 MB at four in parallel, because each instance holds its own memory. B at four in parallel took 4.7 s in round 1 and 6.3 s in round 2, which is again machine state, not a change in the program (same binary, same pages). The run with 8 in parallel is prepared and has not been started.
Hostile PDFs: which renderer survived 29 cases
The test set holds 29 PDFs built to break a renderer. Each ran in its own process with a 90 s wall limit and 3 GiB for the whole run. The service's intake adds its own limits on top: 1 GiB of memory for the child and 60 s per page. The cases are described as classes only: recursion in forms, a compression bomb in a content stream, page counts that lie, huge numbers of drawing operators, deeply nested graphics states, broken cross-reference tables, passwords and absurd page sizes.
Hostile PDFs handled within limits, out of 29, one run per variant. Poppler PNG, Poppler raw PPM and native PDFium: 29. PDFium in Wasm: 28 (red).
| Case class | A, Poppler PNG | B, Poppler raw | C, Wasm | D, native |
|---|---|---|---|---|
| Recursion in forms | ok, 0.3 s, 73 MB | ok, 0.2 s, 98 MB | hung until the 90 s limit, 3,077 MB | timeout after 20.2 s, 1,086 MB |
| Huge operator count | timeout after about 20 s | timeout after about 20 s | timeout, 21.1 s, 388 MB | timeout, 20.1 s, 212 MB |
| Deep graphics-state nesting | resource_limit, 1.0 to 1.5 s | resource_limit, 1.4 s | ok, 2.0 s, 1,232 MB | ok, 0.5 s, 457 MB |
| Compression bomb in a content stream | ok, 2.1 to 2.4 s, 73 MB | ok, 2.6 s, 99 MB | ok, 11.6 s, 1,632 MB | ok, 0.5 s, 628 MB |
| Page counts that lie | invalid_document | invalid_document | too_many_pages | too_many_pages |
A timeout or a resource_limit is a clean, expected end: the child was stopped and the caller gets an error code. The Wasm run on the recursive form is the one case that was not allowed. It hung until the wall limit while memory climbed past 3 GB.
What went wrong: Windows CPU seconds, a corrected number and a memory limit that does not protect
The Windows CPU counting was unusable. A calibration run kept one core busy for 1.03 s wall time, and the counter reported 0.11 s of CPU. With four busy cores it reported 0.52 s. The CPU columns in the raw data therefore must not be compared, and the first estimate of the CPU saving, which came from that counter, is only a pointer. Wall time of a single core on a quiet machine stands in for CPU cost until Linux numbers exist.
A number in the first report on native PDFium was wrong. The first report gave the recursion-in-forms case for D as resource_limit after 3 s. The review checked it against the raw data: the child ended with timeout after 20.2 s at 1,086 MB. The table above carries the corrected values. The outcome is still an allowed one, but a renderer that needs 20 s and a gigabyte for a recursive form is a different risk than one that stops after 3 s.
The Wasm memory limit does not protect the process. The 1 GiB limit on the Wasm runtime caps the module's linear memory. It does not cap the runtime's own memory, such as the call stack held in the Go heap. A hostile PDF can therefore drive the whole service process into a memory abort. A hung page also blocks closing the instance, because closing waits for the running page, and only a kill aborts it. With Poppler or native PDFium in a child process, only the child is hit and the parent receives an error code.
Do the pixels match? Image quality of PDFium against Poppler
B against A: identical. All 36 pairs (18 pages, two resolutions) had 0 differing bytes, in both rounds. This is why raw output needs no new pipeline version: the pixels are the same, so the OCR input is the same. A different pixel stream would count as a new pipeline version, as the post on identical output at any batch size explains.
PDFium is not identical to Poppler:
| Comparison | Resolution | PSNR (min, median, max) | Strongly differing pixels | Page size differs |
|---|---|---|---|---|
| C against A | 150 dpi | 18.2, 24.2, 35.6 dB | median 1.7 percent, max 5.2 percent | 4 of 18 pages |
| D against A | 150 dpi | 18.2, 22.9, 35.6 dB | median 1.6 percent, max 4.3 percent | 1 of 18 pages |
| D against A | 300 dpi | 19.4, 23.7, 43.1 dB | median 1.4 percent, max 2.7 percent | 2 of 18 pages |
"Strongly differing" means a gray difference above 64 of 255. The differences come from edge smoothing, font hinting and often a one-pixel offset: shifted by one pixel, the median PSNR at 150 dpi rises to about 24.5 dB (D) and 25 dB (C). The share of ink (pixels darker than gray 128) differs by at most 0.72 percentage points. On a page with text, a photo, a table and footnotes, the diff images show the same content.
Whether recognition cares is a question only OCR can answer. The comparison with OCR output is prepared and has not run, so the OCR quality of PDFium is open. One more detail for anyone porting: Poppler rounds the image size differently from a plain ceiling, which cost one pixel on one of 18 pages at 150 dpi.
Licence, security record and update effort
Licences. Poppler is GPL-2/3. We start it as a separate, unmodified process and ship none of it: it runs on our server, and a self-hosted customer installs poppler-utils from the distribution. A package that bundled Poppler would carry the duty to offer its source. PDFium under Apache-2.0 and BSD-3 only needs the licence texts to travel with it, so swapping it in would drop that duty. How far the GPL reaches in a given setup is a legal question, and this post does not answer it.
Security. The data here comes from aggregator pages and is unverified at the source. For 2026 it lists five heap-overflow CVEs in PDFium through Chromium, rated high, with no known exploitation found, and several in Poppler, among them an integer overflow in the tiling-pattern path of the raster code that pdftoppm uses (fixed in a Debian security update in June 2026). The pure-Go parser pdfcpu had 15 advisories between 28 September and 27 October 2026, mostly denial of service, one of which kills the process through recursive stream references. A stack overflow in Go cannot be recovered, so a parser inside the service process is fatal where a parser in a child process is not. All renderers parse attacker input, which is the case for a child process with limits whichever one is picked.
Update effort. The pdfium-binaries project published 30 releases between 13 April and 10 November 2026, about one every seven days. Following that cadence needs a gate: render the same 18 pages with each new version and compare the pixels before it goes into a package.
Size and platforms. The PDFium library is 7.8 MB for Windows and 8.3 MB for Linux. The Poppler folder for Windows is 91 MB with its runtime libraries; the Linux poppler-utils with its dependencies was not measured. The Wasm variant needs no C compiler and no foreign binary and runs the same on both systems, but its Go module needs Go 1.26.
What happens next: raw pixels first, native PDFium behind a gate
Raw Poppler output (B) is built into the service intake in the code of 10 November 2026. It was pixel-identical to PNG on this laptop, so it needs no new pipeline version. The same pixel test against the Poppler on the server (Ubuntu 22.04, not measured here) has not run yet, and neither has the Linux timing; until both are in, the numbers above are the Windows ones. The intake reads a strict P6 header, checks the pixel limit before it allocates anything, expects exactly width times height times 3 bytes, and keeps the child's time and memory limits and the same error codes.
Native PDFium (D) comes later and as its own pipeline version, only after four things hold:
- A Linux child is built and measured on the server.
- An OCR comparison of A against D shows no worse character error rate and no silent losses.
- The image size rounding matches Poppler's.
- Each new PDFium version passes the pixel gate.
The Wasm variant is not adopted while its runtime lives in the service process. It stays an idea for a package without Poppler, then as a child process with its own memory limit; nothing of that is built.
Scope and limits of this measurement
- Machine: one Windows 11 laptop (i7-1355U, 12 threads, 31.7 GB) with normal background load. Poppler 26.09.0 was used; the server runs the
poppler-utilsof Ubuntu 22.04. Linux was not measured, so the pixel equality of B against the server's Poppler is still to be shown by the test that goes with the intake. - Rounds: round 1 and round 2 differ in machine state, as B showed with 158 and 88 ms. Within a round the comparison holds; across rounds it does not.
- Pages: 18 pages, medians. Different documents move the picture, mostly at 300 dpi.
- CPU seconds: not comparable on Windows, not yet measured on Linux.
- OCR quality of D: not checked.
- Security data: from aggregator pages only.
- Ghostscript and xpdf: not measured.
A rerun on other hardware needs the same idea: pick pages that cover your own mix of text, photos and scans, render each variant in its own process, and compare the pixels before the speed.
What the service does with this
Today every pipeline renders PDF pages with Poppler at 150 dpi by default; the API also accepts 200 and 300 dpi, and the pipeline is always the caller's choice. Each page goes through a child process with a memory limit (1 GiB) and a time limit (60 s per page), and a hostile file ends as an error code (timeout, resource_limit, render_failed or page_too_large) rather than as a stalled server. The raw-pixel change keeps those limits and codes. PDFium is not part of the service. The pipelines documentation describes the processing steps.
Sources
| Number | Run and file |
|---|---|
| Time per page A, AD, B, C (round 1) | bench/exp/pdf-renderer/ergebnis/lauf-20261110/, 10 Nov 2026: kosten_a_150.json, kosten_a_300.json, kosten_ad_*.json, kosten_b_*.json, kosten_c_*.json |
| Time per page A, AD, B, D (round 2) | bench/exp/pdf-renderer/ergebnis/lauf-d-20261110/, 10 Nov 2026: same file names plus kosten_d_150.json, kosten_d_300.json |
| Peak memory (medians) | the same kosten_* files |
| 50-page load at 1, 2 and 4 in parallel | spitze_a,b,c_1,2,4_50.json (round 1), spitze_a,b,d_1,2,4_50.json (round 2) |
| 29 hostile PDFs | ausfaelle_a.log, ausfaelle_b.log, ausfaelle_c.log (round 1), ausfaelle_a,d.log (round 2) |
| Pixel comparison | qualitaet_150.json, qualitaet_300.json (both rounds) |
| Windows CPU counting | eichlauf_cpu.log |
| Wasm cold start | wasm_kaltstart.log |
| Page sizes, class mix | kosten_* files, fields enc_bytes and klasse |
| Licences, versions, release cadence, advisories | research notes of the renderer experiment of 10 Nov 2026 (summary: doku/experimente/pdf-renderer.md), from aggregator and distribution pages, unverified at the source |