All writing

August 25, 2026 · 6 min read

Counting the pages in a PDF, without a PDF library

Why an OCR endpoint that charges per page has to read the page count itself, and what it takes to do that from the bytes alone.

Our OCR endpoint charges 6.5 credits per page, and it charges before inference runs. Charging first is the only arrangement under which a metered API can refund honestly. The price is fixed at the moment the caller commits. A failure afterwards reverses it automatically, and nobody is asked to trust that the work was worth what it cost.

It has one requirement that turns out to be a real piece of engineering. You have to know how many pages a document has before you have read a single one of them. From the bytes alone, in a request path that is already holding a concurrency lease.

Why this is written by hand#

The obvious move is to add a PDF library. The counter runs in two runtimes and sits in the hot path of a charge. It needs one property that general-purpose parsers deliberately do not offer. Where they guess, it must refuse.

Guessing is the friendlier default almost everywhere. A viewer that renders 47 of 50 pages is more useful than one that shows an error. Here the same behaviour bills someone for the wrong number, and a billing error found by a customer costs more than every page it saved. So the module has one dependency, node:zlib, which bun ships too, and a single rule: every ambiguity throws.

The chain from the last byte to the page count#

A PDF is read backwards. The file ends with a startxref keyword and a byte offset. That offset points at a cross-reference structure, which locates every object in the file. One of those objects is the catalog. The catalog names the page tree, and the page tree carries /Count.

the resolution the counter performs
EOF
 └ startxref  →  byte offset
     └ xref section
         └ trailer /Root
             └ catalog /Pages
                 └ page tree /Count  →  50

five dereferences, any of which can be
in a compressed object stream, and the
xref section can be one of four shapes

Every arrow is a place the file can be honest, unusual, or hostile. The counter scans a bounded window from the end of the file for the last startxref, because a file may carry several and only the last one is current.

Four shapes of the same table#

The cross-reference structure was specified once and then extended three times, and a reader that handles only the original will fail on a large share of documents produced this decade.

ShapeWhat it isWhat reading it costs
Classic xref tablePlain text, fixed-width 20-byte entries in numbered subsectionsLexing, and tolerating the two line-ending conventions in the wild
Cross-reference streamThe same data as a compressed binary stream, PDF 1.5 and laterInflate, then undo a PNG row predictor byte by byte, then read fields whose widths the /W array declares
Hybrid fileBoth, where the classic table is deliberately incompleteNoticing /XRefStm in the trailer and following it, or silently counting a subset
Incremental updateA /Prev pointer chaining backwards through earlier revisionsWalking the chain with newest-wins semantics, and detecting a cycle

The predictor is the part most people are surprised by. A cross-reference stream is not merely deflated; before compression each row was replaced by its difference from the row above, using the same predictor scheme PNG uses for scanlines. Inflating gives you the differences. Recovering the table means adding each row back to its predecessor, in order, before any field can be read.

PNG predictor 12 (Up), one row at a time
inflated   02 | 01 00 0A 00 00
           02 | 00 00 05 00 00
           ^tag  ^difference bytes

row[i] += row[i-1], byte by byte:
recovered  01 00 0A 00 00
           01 00 0F 00 00

field widths come from /W: a 3-field
entry might be 1/2/1 bytes or 1/4/2

Objects can also live inside object streams, which are themselves objects that must be located through the same cross-reference structure and inflated before their contents can be read. Resolving the catalog can therefore require decompressing a stream to find the object that names the stream holding the page tree. None of this has touched a pixel.

Refusing is the feature#

The counter distinguishes eleven failure reasons internally, and maps them to two public codes. A document that only wants a password is told so, because the caller can act on that. Every other reason arrives as one code. That asymmetry is deliberate. The caller needs to know the document could not be read. The reason belongs in our logs, where it is a diagnostic and not an invitation to probe the parser.

Internal reasonWhat it caught
password_requiredA password the document will not open without. Permissions-only encryption, the kind a certificate issuer applies, is decrypted and counted. A document that wants a password is refused before the charge, since counting it would manufacture a charge and a refund
encryptedAn encryption scheme this reader does not implement, such as a public-key handler. The document reads as unreadable
cycleAn xref offset revisited. A /Prev chain that loops
too_deepMore than 64 xref sections, 64 levels of value nesting, or 32 hops in a reference chain
bad_startxrefNo startxref in the trailing window, or an offset outside the file
unsupported_filterA filter chain this reader does not implement
bad_catalog, bad_pages, bad_countThe chain resolved to something that was not what it claimed to be

Inflation is bounded at 32 MB of output. A cross-reference stream is a compressed object supplied by a stranger, and inflateSync will happily produce a gigabyte from a few kilobytes if asked.

Note

There is no fallback that scans the file for page markers. That fallback returns a number that is sometimes right, and a number that is sometimes right is worse than an error, because an error is visible and a wrong charge is not.

A document that lies about its own count#

/Count is a value in the file, and a file can be wrong about itself. The counter cannot detect that, and does not try. The check happens downstream instead, at the provider boundary. The read must come back with exactly the number of pages that were counted and charged for. Anything else refuses the delivery and refunds the charge.

This is the general shape of the answer whenever a cheap check cannot be made sound. Put the cheap check where it belongs, and put a second, exact check where the truth is available.

Images are simpler, and the trust boundary is the same#

PNG, JPEG, and WebP are each one of the 4 accepted formats, and each is one page. What matters there is sniffing. The format is read from the magic bytes, and a field the caller declared is ignored. A declared field is an assertion by a stranger, and the bytes are the document. The sniffed format is what usage metadata records and what ships to the provider, which is what keeps the three in agreement.

What the trade actually bought#

Per-page billing looks like the simplest pricing model there is, and from the outside it is exactly that. The complexity did not vanish. It moved into one dependency-free module with a unit-test suite built from programmatic fixtures, where a failing case arrives as a test and nobody has to argue about it.

That is available to anyone metering documents, and it is the part worth copying. Decide what the price depends on. Make that quantity readable before committing to it, and refuse when it cannot be read.