When the numbers on the page disagree

Four signal families read the figures a document prints: check digits that do not add up, a balance column that stops following, and an amount sitting off the line it was printed on.

These four families read the document's arithmetic and geometry, not its structure. Launder a file through Ghostscript and its revision chain and producer string are gone; the numbers still have to add up, and the glyphs have to land where a generator would put them.

Part of the field guide. Four of the nineteen families the engine reports.

Signals, not verdicts. Every finding here is a structural fact about a file. "This document was modified after generation" is true or false about the bytes; "this document is fraudulent" is a judgement about a person, and Tamperlens does not make it. The API returns no boolean verdict field, by design.

A full re-save through another tool regenerates every structural signal in one pass. What it cannot regenerate is what the printed figures add up to.
an edited document gs · qpdf · print a brand-new file WRITTEN FRESH: THESE GO SILENT the revision chain and every earlier state the trailer /ID pair the producer string and both date pairs the font subset prefixes every origin family reads one of these UNCHANGED: THESE STILL READ a boleto's printed check digit a balance column that has to follow where each glyph sits on its line a property of the claims on the page, not of the container around them And the honest half: a forger who re-derives the column, or retypes nothing at all, defeats both of these too.

br-identifier-checksum

high medium low
What it detects
A Brazilian identifier printed on the page whose own published check digit says it is wrong: a CPF, a CNPJ, an NF-e access key, or the 47-digit linha digitável of a boleto.
The boleto is the one worth the family. A payment slip's printed line is not free text: it is a fixed rearrangement of the 44-digit barcode, and its fourth field carries the mod-11 check digit of exactly the amount and due date the rest of the line encodes. Reconstruct the barcode from the printed line, run the check, and an amount somebody retyped contradicts arithmetic the issuing bank already performed, with nothing to compare it against. No barcode is decoded and no pixel is read.
Retyping the amount on a boleto contradicts a check digit the issuing bank already computed: so the page argues with itself, and no reference copy is needed.
THE PRINTED LINE: 47 DIGITS IN FIVE FIELDS field 1 field 2 field 3 DV field 5: date + amount field 4: one mod-11 digit computed over the whole payload a fixed rearrangement, not free text the reconstructed 44-digit payload: the same digits the bars carry the recomputed digit disagrees high: a correctly-issued slip cannot have this the recomputed digit agrees nothing is reported: it is worth nothing Roughly one arbitrary digit run in ten fails this check by chance, so a bare 47-digit run is only read as a boleto on a page carrying at least three Febraban field names. Without it the family would say “somebody edited a boleto”.
That matters beyond boletos: it is one of the few findings here that survives a document being re-generated whole. A qpdf or Ghostscript rewrite launders every byte-level signal; it does not change what the digits on the page add up to.
Evidence returned
Per finding: the kind, the page, the shape it was recognised by, which component failed, the digit count and only the last four digits. For a boleto, the amountCentavos and dueDate the line encodes. The number itself is never republished. A CPF is personal data, and a report gets stored, logged and forwarded.
Benign causes
Typos and transcription. A failed CPF is more often a mistyped one than a fabricated one, which is why it is reported at low and never higher.
Deliberately invalid test numbers. Blank forms and templates carry 111.111.111-11 and friends by design; repeated-digit numbers are excluded outright.
OCR. A scanned page's text layer carries transcription errors that say nothing about fraud, so full-page-image pages are not read at all.
What it cannot see
This is not an identity check and not a lookup. No registry is consulted and nothing leaves the process. Knowing that eleven digits satisfy a published identity says nothing about whether they belong to anyone, or to the person named beside them.
A passing check digit is never reported, because it is worth nothing. Anyone can generate a valid CPF in a loop, and every template filler already does. Silence here is not a clean bill of health.
The barcode itself is not read. Comparing the printed line against the bars, the third carrier, needs a vector barcode decoder that is specified and not built; see docs/CORPUS-PHASE0-BOLETO-2026-08-05.md for the measurement that decided against it.
Severity logic
high when a boleto's printed line, in its canonical punctuated form, fails the general mod-11 check digit: the failure an edited amount or due date produces, and one a correctly-issued slip cannot have. medium when a single field's mod-10 digit fails (one mistyped block, which transcription also produces), when the same general failure appears in a bare digit run, or for an NF-e key. low for a CPF or CNPJ.
A bare 47-digit run is only read as a boleto on a page that carries at least three Febraban field names. Roughly one arbitrary digit run in ten clears the layout guard and then fails a check digit, and without that requirement the family would state it as "somebody edited a boleto".

running-balance-break

medium
What it detects
A balance column that stops following from the rows beside it. A running balance is a chain: each row's balance is the previous row's balance plus whatever that row records. A column that satisfies this five times in a row is a running balance, and a step it cannot account for is a step the numbers printed beside it do not explain.
A column is only treated as a balance after it has proved it is one, five links holding in a row, because most number columns in real documents are not balances at all.
EACH LINK IS ONE ROW CONSTRAINING THE NEXT row amount balance opening 1,000.00 transfer in +250.00 1,250.00 fee −12.00 1,238.00 payment −90.00 1,098.00 payment −110.00 988.00 start holds holds breaks holds 1,238.00 − 90.00 is 1,148.00, and the page prints 1,098.00. Rows below re-link normally, which is why the evidence names which link broke first. THE PRECONDITION IS THE FAMILY At least five links must hold and at most two may break. A table that is not a running balance breaks at nearly every link and is dropped in silence. Tolerance is two cents plus one basis point: rounding, not a conversion.

Illustrative rows, shortened: a real finding needs a column that has already held at least five links. The evidence returned is how far the balance moved across the break, no page text and no standing balance is copied into a report somebody else will store and forward.

This is the only family that can see a document generated cleanly from scratch. Every other family here asks whether a file was changed (a revision was appended, two dates disagree, a run is painted under a box), and all of them assume there was an original. A statement produced whole by a template filler, in one revision, with coherent metadata and one producer, passes all of them. What it does not do reliably is stay internally consistent, because the rows are invented one at a time and the chain constrains every one of them at once.
Evidence returned
Per chain: the page, how many rows and links it had, how many links held, how many broke, which link broke first, and how far the balance moved across it. Plus the number locale the document resolved to and how it was decided.
No page text is returned, and no balance. The movement is a difference of two balances, the finding, while the account's standing position is not something this engine copies into a report body somebody else will store and forward.
Benign causes
A balance carried forward. A chain that continues from a previous page or a previous statement legitimately starts from a number this page never shows.
A row we could not read. A fee line drawn as a picture, a figure with something painted over it, an amount in a form we do not walk. All of them are simply absent, and an absent amount looks exactly like a missing one.
Rounding and multiple currencies. The tolerance is two cents plus one basis point, which absorbs rounding but not a conversion.
What it cannot see
A scanned statement. There is no text layer to read and no OCR here, so the family says nothing at all, which is not the same as saying the document is fine.
A document whose number locale is ambiguous. 1.234,56 is one thousand in Brazil and one-point-two in the United States. The locale is decided once per document from its own separator usage, and a document that uses both conventions is declined rather than guessed at.
A forger who re-derived the column. Recomputing every balance after an edit defeats this entirely. The family raises the floor; it does not close the door.
Severity logic
medium, and deliberately no higher. An arithmetic finding reads like proof in a way a structural finding does not, so it is capped until measurement says otherwise. Measured across 3,842 published documents it fires zero times, and the same measurement found no real bank statement in that corpus, so its true-positive rate is demonstrated on fixtures and not on a population. Precision measured at zero fires is not precision measured high.
Zero false positives across 3,842 published documents is not evidence this check is precise: the corpus holds no bank statement for it to have been wrong about.
3,842 published documents scanned 0 fires 18 candidate chains cleared the precondition and not one of them is a bank statement The corpus holds banks' published fee schedules, not customers' statements, and never will. So this family's true-positive rate is demonstrated on fixtures rather than on a population.
The precondition is the family: a column must hold at least five links and break at most twice. A table that is not a running balance breaks at nearly every link and is dropped in silence: 18 candidate chains exist in that corpus and not one of them is a statement.

glyph-anomalies

low
What it detects
A value that does not sit on the baseline of the text printed beside it, on a line that carries a money amount. A generator places every glyph of a printed line from one text matrix, so the line is flat. An editor that replaces a value cannot re-flow the page: it patches the content stream, re-derives the translation, and the replacement lands a fraction of a point out.
It is geometry, not structure. That is the only reason it is here: a revision count, an /ID pair and a producer string are all erased by re-saving the document through another tool, and where the glyphs land is not. It is one of the few findings that can survive a full re-distill.
A generator places every glyph of a printed line from one text matrix, so the line is flat. An editor patching one value re-derives the translation for that run alone, and it lands a fraction of a point out.
AS THE GENERATOR WROTE IT: ONE TEXT MATRIX Total due R$ 1,240.00 30/09 every run shares a baseline, because one matrix placed them all AFTER ONE VALUE WAS PATCHED INTO THE CONTENT STREAM Total due R$ 9,240.00 30/09 the offset, in ems an editor cannot re-flow the page, so it re-derived the translation for that run alone A raised run must be at least 85% of the size of the text beside it, so superscripts and footnote marks are excluded by construction. And a value retyped alone on its own line has no neighbours, so it has no baseline to be off.

The drawing exaggerates the offset to make it visible. No font program is parsed and no /Widths array is loaded. A font program is attacker-controlled binary, so only quantities the content stream states literally are read.

Evidence returned
Per finding: the page, the baseline offset in ems, the number of runs on the line, and three booleans recording whether the same line also showed a font-subset change, explicit kerning inside a number, or a small size divergence. No page text is returned. The runs this family looks at are the amounts on the page.
Benign causes
Design and layout tools that place text element by element. Measured across 1,368 published documents this fires on 24 of them, and the producers are Canva, Quartz, Distiller, Word, pdfTeX and iText. Every mainstream toolchain. There is no producer to exclude.
Superscripts, footnote marks and inline notation are excluded by construction: a raised run must be at least 85% of the size of the text beside it, which a superscript is not.
What it cannot see
Scanned documents. A scan's text layer is drawn in invisible render mode, so it is not text a reader sees and this family never reads it. About a third of both reference corpora draw no visible text at all, and silence on those is not a clean bill.
Anything about glyph widths. No font program is parsed and no /Widths array is loaded. A deliberate exclusion, since a font program is attacker-controlled binary. Only quantities the content stream states literally are read.
A value retyped alone on its own line. The check compares a run against its neighbours, so a figure with nothing beside it has no baseline to be off.
Severity logic
low, always, and the ceiling is measured rather than cautious. Across 1,006 forgeries paired against their own sources this finding appears on 43 and on 1 of the 160 sources; the operations that retype text fire it 6.6% of the time where their own source did not, and the 228 forgeries that retype nothing fire it 0.0% of the time. It also appears on 1.4% of US and 2.2% of Brazilian published documents, and it newly flags 3 of the 82 forgeries the rest of the engine misses. A severity of low carries no score floor, so this finding cannot on its own lift a clean document out of the low band, which is the honest weight for a residue that legitimate typesetting also produces. See docs/GLYPH-PHASE0-2026-08-05.md.
The ceiling is measured, not cautious: the forgeries that retype nothing fire this 0.0% of the time, and published documents fire it about as often as the forgeries that do.
HOW OFTEN IT FIRES, PAIRED AGAINST EACH FILE'S OWN SOURCE forgery operations that retype text 6.6% the 228 forgeries that retype nothing 0.0% US .gov published documents 1.4% Brazilian published documents 2.2% 0 5% 10% what it adds 3 of the 82 forgeries the rest of the engine misses what the severity costs low carries no score floor

Measured across 1,006 forgeries paired against their own sources, plus 1,368 published documents, 24 of which fire it, spread across every mainstream toolchain, so there is no producer to exclude.

structure-warnings

low medium
What it detects
Everything the parser could not reconcile between the file's own bookkeeping and its actual bytes: cross-reference entries pointing at offsets that do not hold the object they claim, unreadable or reconstructed cross-reference sections, malformed subsections and entries, cross-reference chain loops, stream lengths that disagree with the stream, dangling references, and bytes trailing after the final %%EOF.
A correctly written PDF is internally self-consistent. Irregularities mean either a non-conforming producer or post-hoc manipulation of the bytes, and they also bound how much of the document the rest of the report could examine, which is why they are reported even when they look boring.
The split is between a file whose bookkeeping contradicts its bytes and one whose bookkeeping is merely incomplete, and neither is a statement about authenticity.
MEDIUM: THE FILE CONTRADICTS ITSELF xref-conflict · bad-offset unparseable-xref · bad-xref-stream bad-xref-subsection · bad-xref-entry xref-chain-loop · xref-reconstructed stream-length-mismatch an entry points where the object is not LOW: THE BOOKKEEPING IS MERELY UNTIDY bytes trailing after the final %%EOF dangling references everything else the parser could not reconcile Files that travelled through email gateways, virus scanners or naive transfer paths pick up trailing bytes routinely. READ EITHER ONE AS A BOUND, NOT AS A VERDICT Non-conforming producers are everywhere: plenty of honest long-lived generators emit slightly wrong offsets, because every real PDF reader repairs them silently. A truncated download produces a spectacular version of this signal and means nothing at all about the document.
This family also carries the encrypted-document disclosure, described in full in the next section.
Evidence returned
warningCount, the deduplicated sorted codes list, up to 25 individual warnings with messages, xrefConflicts, trailingGarbageBytes, revisionChainBroken and parseTruncated.
Benign causes
Non-conforming producers are everywhere. Plenty of long-lived, entirely honest generators emit slightly wrong cross-reference offsets, because every real-world PDF reader repairs them silently and nobody ever noticed. Files that have travelled through email gateways, virus scanners or naive transfer paths can pick up trailing bytes. A truncated download produces a spectacular version of this signal and means nothing about the document's authenticity.
Severity logic
Silent when there are no warnings and the document is not encrypted. medium when there is at least one cross-reference conflict, or at least one warning whose code is in the serious set, xref-conflict, bad-offset, unparseable-xref, bad-xref-stream, bad-xref-subsection, bad-xref-entry, xref-chain-loop, xref-reconstructed, stream-length-mismatch, that is, the file's bookkeeping contradicts its bytes rather than merely being incomplete. low otherwise.

Run these checks on a statement

Open the “Saved again, page changed” sample: a precomputed report on a fictional statement whose second revision rewrote the printed total, which is the shape the arithmetic families here are built for. Your own file goes into the same checker: it is sent over HTTPS, parsed in memory and never written to disk. No account, no quota. To run it in your own pipeline, see the API quickstart.

Tamperlens reports risk signals, not authenticity verdicts. Signals can have benign causes; combine them with your own decision logic.

The rest of the guide