Facts about documents, for readers who cannot inspect them.
What is inside a PDF, cited to byte offsets, reproducible, and checkable without trusting us. Not whether the document is genuine — that decision belongs to whoever bears the consequences of getting it wrong.
—Summary
TrustPDF reports what is inside a PDF: how many times it was revised and which objects each revision changed, what its signatures actually cover and whether the signed bytes still hash to what the signer attested, and what the document says about its own origin.
Every finding cites the bytes it came from. Every check that could not run is listed with the reason it could not. The same file always produces the same report, and the report records the engine and ruleset version that produced it.
It does not decide whether a document is genuine. This paper explains why we consider that a design requirement rather than a missing feature.
1The problem
Documents have always been forgeable. What changed is who reads them.
A PDF arriving at an organisation used to be opened by a person who might notice that a font looked wrong, that a total had been re-typed, that the layout of an invoice differed subtly from last month's. That reader was slow, expensive, and inconsistent — but they were a reader.
Increasingly the first and only reader is an automated system: an accounts-payable pipeline extracting an IBAN, a lending platform parsing a bank statement, a rental application service reading a payslip, an agent asked to summarise a contract and act on it. These systems are excellent at extracting what a document says. They have no capacity whatsoever to evaluate whether it should be believed.
Software that reads documents in order to act on them is becoming ordinary. The value of altering a document rises with the autonomy of whatever reads it next.
1.1 What the format permits
PDF is not a document format in the way a JPEG is an image format. It is a container with an object graph, an update mechanism, and a signature scheme, and each of those leaves evidence when misused.
- Incremental updates. A PDF may be modified by appending, leaving every prior state present in the file. The visible document is the last state; the earlier ones are still there to be read.
- Signature byte ranges. A signature declares which byte ranges it covers. It is entirely possible for a file to carry a cryptographically valid signature that covers only part of what a viewer displays.
- Producer metadata. Documents record the tools that made and modified them. This is trivially editable and therefore weak evidence — but its absence and its inconsistencies are themselves facts.
None of this is exotic. It is how the format works, and it means a great deal is knowable from the bytes without any judgement being applied.
2Why we do not render a verdict
The obvious product is a score. Upload a document, receive
risk: 0.72, or a red badge reading tampered. We have
deliberately not built that, for four reasons.
A verdict cannot be verified. An observation like “bytes 1222–1255 are not covered by the signature” can be confirmed by anyone with a hex editor. A score of 0.72 can only be trusted or not. For a product whose entire premise is trustworthiness, shipping the one output nobody can check is self-defeating.
The threshold is not ours to set. A rental agency approving a tenancy and a bank underwriting a mortgage have entirely different tolerances for the same evidence. Any score embeds a threshold decision, which means it is miscalibrated for most of its users permanently.
Scores do not compose; facts do. An automated caller can write
if not signature_covers_whole_file: escalate. It can do nothing
principled with a number except defer to it — which is precisely what a careful
integrator will refuse.
Accusation carries liability we decline to take on. “This invoice is fraudulent” is a claim about a person. “Object 41 declares a ByteRange leaving bytes 88,214–91,002 uncovered” is a claim about a file. We make only the second kind.
The name is not a promise that we will sort trustworthy documents from untrustworthy ones. It is a claim about the report: you can trust what we tell you about the PDF, because it is reproducible, cited, and checkable without trusting us.
3The observation model
Every finding the engine can emit is defined in a catalog that the engine parses at startup. The engine cannot emit anything the catalog does not define, and it refuses to start if any detector claims an identifier the catalog does not contain.
| Part | Purpose |
|---|---|
| id | Stable identifier, e.g. SIG.BYTERANGE_GAP. Callers match on this, never on prose. |
| statement | Generated from a template, never hand-written. Identical inputs produce identical text. |
| evidence | Byte offsets, object numbers, extracted values — enough to confirm or refute the statement independently. |
| causes | What the observation can mean, benign and adversarial, side by side and unweighted. |
causes is the part that keeps the product honest. Consider a
signature that does not cover the end of a file. The benign reading is that a
viewer appended annotation data after signing, or that a second party
countersigned. The adversarial reading is that content was inserted after
signing. We state both and weight neither, because the file alone does not
distinguish them.
3.1 Undetermined is a first-class result
A report has two lists: observations and undetermined.
The second exists because silence is the most dangerous thing a forensic tool
can produce.
If certificate chains were not validated, the report says so and says why. If a file is linearized and its revision history is therefore unrecoverable, the report says that too. A caller must never be able to read the absence of a finding as evidence of cleanliness.
This principle has teeth in the implementation. A detector that raises an
exception becomes an undetermined entry naming the failure. A
malformed internal message becomes one. A file exceeding the size ceiling
becomes one. Nothing fails quietly.
4Design commitments
These are enforced in code and in the project's own rules, not merely intended.
Determinism. The same bytes produce a byte-identical report. There is no model in the path, no wall-clock, no randomness, and no reliance on iteration order — every collection is sorted before it enters the report. This is what makes a report re-runnable years later as evidence.
Versioning. Every report carries an engine version and a ruleset version. A finding is reproducible by pinning both. Trust anchors, when they arrive, will be pinned per ruleset version rather than fetched live, because a report that validates differently next year is not evidence.
No document is stored. The document exists as bytes for the duration of one call. It is never written to disk, never logged, never echoed back. What is retained is its SHA-256 digest, computed in transit, and the report — byte ranges, object numbers, whether a signature exists. A digest does not turn back into a file.
A report is kept, and that follows from determinism rather than sitting awkwardly beside it. Because the same bytes under the same engine produce a byte-identical report, a stored report is the same answer, not a substitute for one. Reports are held against the digest and the engine that produced them, so a caller who sends bytes they have already been given a report for is handed it back rather than billed for the same work twice. A ruleset change has no row to find.
Two constraints follow. A repeat is disclosed only when it is the caller's own, with the date they first received it and at no charge. And nothing reveals whether anyone else has sent a document — not a flag, not a response time, not an allowance that failed to move. For a document-forensics service that would be a fact about a third party, and uploading a copy must not be a way to learn it. The three signals are closed together or not at all; closing two of three closes nothing.
Reports are not published. Keeping a report against a digest is not the same as making one shareable. There is no permalink feature today because a shareable report would put extracted document content somewhere a link-holder could read it; the intended design is encryption in the browser with the key in the URL fragment, which is never sent to a server.
Adversarial input is the assumption. Bounded recursion, explicit ceilings on size and structure, no network egress from analysis, and document content never reaching logs.
Minimal dependencies. The engine installs nothing outside the
Python standard library and deploys no native code. Cross-reference streams are
decompressed with zlib; CMS message digests are read with a small,
deliberately partial DER walker that reads two values and refuses everything
else. Libraries are permitted where they encapsulate hard, audited work — the
known future case is PAdES chain validation — but nothing is carried
speculatively.
5Architecture
The engine is a pure library. analyze(bytes) -> Report knows
nothing about HTTP, its host, or how it was invoked.
bootstrap() ──▶ Bus ──▶ analyze(bytes) ──▶ Report ▲ readers ─┤ produce frames from bytes detectors ─┘ consume frames, emit findings
Readers touch bytes and publish structured frames — a revision boundary, a cross-reference section, a signature's declared range. Detectors consume frames and emit findings. The direction is one-way: a detector is handed an emitter rather than the bus, so it structurally cannot publish a frame or reach another detector.
The consequence is that replacing any node requires understanding its frame contract and nothing else about the system.
Contracts are validated before a byte is read. At startup the engine refuses to run if a detector emits an identifier absent from the catalog, subscribes to an unknown frame, subscribes to a frame no reader produces, or claims a finding another detector already claims. All problems are reported together rather than one deploy at a time.
Fresh nodes per document. Detectors accumulate across frames and flush at the end, so node instances are constructed per analysis. This is structural, not a convention — an earlier version reused instances and leaked one document's findings into the next, which is the single worst defect this engine could have.
6What is implemented
Seven of the catalog's eight families are complete. The eighth needs a raster decoder rather than more code, and §8 says so plainly along with everything else that is not built.
6.1 REV — revision history
A PDF saved incrementally carries every prior state. Each state ends at
%%EOF preceded by startxref and an offset, and the
cross-reference section at that offset lists exactly the objects that revision
wrote — so changed objects are read directly rather than derived by diffing.
Both cross-reference forms are supported: classic tables, and compressed streams
including /Index ranges, /W field widths, and PNG
predictors.
Two traps are handled explicitly, because both cause false accusations:
-
%%EOFoccurs inside content streams. A boundary is only real ifstartxrefand an offset precede it. -
A linearized file carries a second
%%EOFby construction and ends withstartxref 0, which is a marker meaning “the table is at the front”, not an offset. Reading either naively reports edits on untouched documents.
Where linearization makes history unrecoverable, we report
REV.HISTORY_UNAVAILABLE rather than guessing. This under-reports
genuine edits, which is the correct direction to fail.
6.2 SIG — signature coverage and signed-content integrity
A signature declares two covered ranges with the signature blob in the hole between them. That hole is expected; anything else uncovered is content the signature does not vouch for.
The engine distinguishes two cases that look identical from byte offsets alone:
-
SIG.COVERAGE_SUPERSEDED— the uncovered region holds a later signature. Ordinary in a countersigned document. -
SIG.BYTERANGE_GAP— the uncovered region holds content nothing signs. This is the shape of an incremental-update attack.
Telling them apart requires the revision boundaries, so the coverage detector consumes frames from both families.
Separately, the engine recomputes the digest over the covered ranges and compares
it to the messageDigest attribute the signer attested in the CMS
structure. A mismatch means signed content was altered. This check requires no
certificate, no chain, and no trust decision — which is why it runs on every
document at negligible cost.
Chain validation is not performed (§8). Every signature
therefore carries SIG.CHAIN_UNRESOLVED stating that no chain was
built and why, so a valid digest can never be misread as a validated
certificate.
6.3 META — origin metadata
The Info dictionary, the trailer /ID pair, and XMP processing
history. Values are read from the last occurrence in the file, because that is
the revision a reader sees.
This family fires on ordinary documents where the other two are often silent. A real example from the measurement corpus:
Creator “Microsoft® Word pour Microsoft 365”, Producer “Microsoft® Word pour Microsoft 365; modified using iText® 7.1.17”.
Word produced it; iText later modified it. We report that and draw no conclusion.
XMP is parsed with bounded regular expressions rather than an XML parser, because the input is hostile and standard XML parsers are vulnerable to entity-expansion attacks that regular expressions cannot perform.
6.4 STRUCT — file structure
Every finding here has the same shape: two readers of the same bytes disagree about what the document is, and neither is obviously wrong. That is why they are reported rather than resolved.
A cross-reference entry that does not point at the object it names is the clearest case — a viewer that trusts the table and one that rebuilds it from the bytes will resolve that object differently. Both offsets are reported, because either alone is unverifiable.
Reachability and catalog counting need the object graph, so the reader that answers them parses shallowly: each object yields its number, its type and the objects it names, and nothing else. Compressed object containers are opened, because most objects in a modern PDF live inside one and the references they make are what reachability is computed from.
An object still in use that nothing reaches is reported. So is a second catalog, alongside the one the trailer appoints — the finding is that a reader must choose, not that the file is wrong.
Objects 277, 278 … 331 are present in the file but not reachable from the document catalog.
That is a real result from a five-revision signed document. Its referrers are all in revisions a later save superseded, which is exactly the benign reading the catalog states first. We report it and draw no conclusion.
6.5 FONT — how the glyphs got there
A producer embeds fonts one way for a whole document, so every finding here is a comparison rather than a property: one font against the others on its page, or one object against another claiming the same name.
A font embedded whole on a page where every other font is subset-embedded was
put there by something else. Two objects declaring the same BaseFont while
embedding different programs are two generations of a document merged. And a
run whose font differs from the runs on both sides of it, inside one
BT/ET block, is a seam — but only when that font
appears once in the block, because emphasis recurs and a sentence with three
bold words would otherwise qualify three times.
Font programs are decompressed before they are hashed. The same font written at two compression levels is one program, and a digest saying otherwise would report a difference that does not exist.
6.6 TEXT — the layer, and what it sits on
Answering these means running the page. A content stream is a stack machine: a run's position is the product of every transform before it, and its colour is whatever the graphics state held at the moment it was shown. No scan gives either.
So the engine runs the subset that decides the question — transforms, text and line matrices, render mode, fill colour in grey, RGB and CMYK, and enough path handling to know what a filled rectangle covered. That last part is not incidental: white text on a white background is a claim about the background, and comparing against the page instead of against what was painted beneath the text reports every reversed-out heading ever designed.
Text invisible over a scan is what every OCR pipeline produces. Text visible over a scan is a stamp — or a value painted over what the scan shows. We report the structure and never the difference.
An OCR layer is thousands of runs over one image and is reported once. Where nearly all of a page's text sits over one image, the image is the page rather than something on it, and that is one fact too.
6.7 ACTIVE — what the document does
Found by walking, not by searching. The ordinary approach is to
search a file for /JavaScript and report a hit. That answers a
different question from the one a reader has: a PDF carries every prior revision
inside it, so a script some later save removed is still in the bytes, and a
search reports it as though opening the document would run it.
This starts at the catalog and follows what a viewer follows — the open action, additional-action dictionaries, the JavaScript and EmbeddedFiles name trees, then each page and its annotations. Everything reported is something the document can actually do, and because it was reached along a path, the path is what the report cites.
That path carries the distinction worth having. A script in the open action runs before anyone touches anything; the same script on a form field is how field validation works. Neither is a verdict and neither carries a score.
A script's source never leaves. Its length and digest do, which identifies it across documents and reveals nothing on its own. An attachment's type is reported as declared — sniffing the bytes to say what a file really is would be a conclusion, and the digest lets anyone check the claim.
7Measurement
All figures come from the project's survey tool run over 4,272 real PDF files, aggregating counts only — no filenames, no paths, no document content.
That corpus is local, and it is not traffic. It was surveyed offline to measure the reader. None of it passed through the live service, none of it was charged for, and none of it appears in any public count. The figure in this page's footer counts a different thing — documents analysed through the running service since it began counting — and it is a far smaller number. The two are not versions of one statistic, and neither is folded into the other to make it look larger.
7.1 Performance
| Median analysis | 1.6–1.9 ms |
| p95 analysis | 2.7 ms |
| Largest file analysed (53 MB, before the ceiling) | 161 ms |
| Median file size | 241 KB |
| p95 file size | 354 KB |
Compute is nowhere near a constraint. Three regressions found during development were all regular expressions applied across whole files — one cost five seconds per megabyte, one cost 890 ms on a 53 MB file. Both were fixed by anchoring searches on literals.
7.2 Coverage
| Observation | Count in corpus |
|---|---|
| SIG.ABSENT | 4,263 |
| META.DOCID_INSTANCEID_DIFFER | 16 |
| STRUCT.LINEARIZED | 12 |
| META.PRODUCER_CREATOR_MISMATCH | 12 |
| META.STRIPPED | 12 |
| REV.PRESENT | 7 |
| META.XMP_HISTORY | 6 |
| REV.OBJECTS_CHANGED | 6 |
| SIG.COVERAGE_SUPERSEDED | 1 |
| cross-reference sections unreadable | 0 |
Only 4 signatures appear across the whole corpus, in 3 documents.
This survey predates the STRUCT, FONT, TEXT and ACTIVE families, and a count absent from the table was never measured rather than measured as zero. Those detectors were built after this run and none of them appears above. Their prevalence on real files is unknown, and this page will not carry a figure for them until the survey is run again — an omission stated is worth more than a number implied.
7.3 What this corpus does and does not tell us
It is the wrong population, and we say so. These are documents found on a working laptop: reports, manuals, statements, exports. Almost none are signed and almost none carry revision history.
Contested documents — the ones this product exists for — are structurally
different. Applying a signature requires an incremental save, so signed
documents necessarily carry the history the REV family reads. A
corpus of disputed invoices, altered statements and countersigned contracts would
show entirely different frequencies.
What the corpus does establish is the thing it is well suited to establish: that the engine does not over-fire. Across 4,272 ordinary documents it produced no false revision claims, no false coverage gaps, and no unreadable cross-reference sections.
For a tool whose worst possible failure is accusing an untouched document, that is the property worth measuring on exactly this population.
7.4 Proving a detector fires
The survey establishes the half that matters most — that the engine does not accuse ordinary documents. It cannot establish the other half. Four thousand untouched files say nothing about whether a detector works, and a detector that never fires at all passes that test perfectly.
Real tampered documents would settle it, and they carry a problem of their own: nobody knows what the right answer is, so a finding can only be argued about. So the engine ships a generator. One document is built with the shape a real one has — a page tree, inherited resources, a subset font and a whole one, an image, a compressed content stream, a classic cross-reference table, metadata — and ten mutations are applied to it, each declaring in advance the observation it should produce. They span every family that emits.
The check runs in both directions, and the second caught more: every mutation is compared against what the untouched document already reports, so a finding present before the tampering cannot be mistaken for a consequence of it.
This is not a substitute for real files and is not offered as one. Every mutation is one we thought of, applied to a file we wrote, so it cannot surface a structure nobody here imagined — which is precisely where every real defect has come from. The same mutations apply to documents from other producers, which is the bridge: realistic structure, and an answer still known in advance.
8Limitations
Stated here rather than discovered later.
No certificate chain validation. Trust anchors (AATL, EUTL) must
be pinned per ruleset version so reports remain reproducible; fetching them live
would break determinism and leak which certificates are being inspected. Until
then every signature reports SIG.CHAIN_UNRESOLVED.
We can tell you the signed bytes are unaltered. We cannot yet tell you who signed them.
No revocation checking, for the same reason.
No image analysis. Recompression artefacts and resolution discontinuities — the evidence of a region being replaced in a scan — require raster decoding. Not built.
Composite text reports no characters. A Type0 font's codes are glyph indices, not letters. Without the font's own ToUnicode map, rendering them as text would invent words — so the run is positioned and measured, and nothing is claimed about what it said.
Active content is reported only where a viewer would reach it. Scripts and actions are found by walking from the catalog, so one left behind in a superseded revision is not reported — it is in the bytes, and opening the document will never run it. A report is evidence about what a document does, not an inventory of everything it contains.
Text position is the format's, not a renderer's. Boxes come from the text matrix and the font's own widths. A page that positions glyphs individually, or one whose font ships no widths, is measured more coarsely than a rendering engine would measure it.
Reachability depends on containers opening. Objects packed into a compressed container are read; one this ruleset cannot decompress is left closed, and the objects it held are neither reported reachable nor reported orphaned.
Linearized files under-report. History is reported unavailable rather than partially reconstructed.
4 MB ceiling. Measured to cover 99.86% of real files. Larger
documents report LIMIT.SIZE rather than being analysed.
Metadata is weak evidence and we treat it as such. Every
META finding describes something trivially editable. They are
reported as facts about the file, never as indicators.
9Interface
The product is an API and MCP endpoint first, and a web page second.
The web interface renders both readings of every observation with identical
styling, uses no status colours, and gives undetermined its own
section — the copy discipline of §2 and §3 made visible.
But the primary consumer is not a person. It is the automated reader from §1: the pipeline extracting an IBAN, the agent about to act on a contract. Those callers need structured facts with stable identifiers, evidence they can inspect, and both readings of each finding so they can apply their own policy.
That shapes the roadmap:
- A stable report schema, because integrators build against it and it is therefore the real product surface.
- An MCP endpoint, so an agent can consult the engine as a tool before acting on a document rather than after.
- Per-call metering rather than accounts and contracts, because the buyer is a machine and the unit of value is one call.
- Reference policies — open, versioned mappings from observations to recommended actions — that a customer adopts and owns. This is how someone who wants a verdict gets one without us shipping a score: the threshold is theirs, stated explicitly, and auditable.
10Appendix: a report
A document signed, then altered within the signed range, then appended to:
{ "engine": { "version": "…", "ruleset": "…" }, "input": { "sha256": "26c3d285…", "bytes": 1255 }, "observations": [ { "id": "SIG.BYTERANGE_GAP", "statement": "Signature in object 2 covers bytes 0–179, 1205–1222. Bytes 1222–1255 are not covered by the signature.", "evidence": { "object": 2, "covered": [[0, 179], [1205, 1222]], "uncovered": [[1222, 1255]], "revision": null }, "causes": { "benign": "A viewer appended annotation or form data after signing.", "adversarial": "Content inserted after signing (incremental update / shadow attack)." } }, { "id": "SIG.DIGEST_MISMATCH", "statement": "The message digest in signature object 2 does not match the computed digest of the bytes it covers.", "evidence": { "object": 2, "algorithm": "sha256" }, "causes": { "benign": "File corrupted in transit or by a re-saving tool.", "adversarial": "Signed content was altered." } } ], "undetermined": [ { "id": "SIG.CHAIN_UNRESOLVED", "statement": "The certificate chain for signature object 2 was not validated: this ruleset pins no trust list, so no chain was built." } ] }
Two facts, each cited to byte offsets, each carrying both readings. One check that did not run, saying so.
No score. No verdict. That is the whole point.