Tutorial - a reader that checks itself
Build a complete statement reader from an empty file. Six steps, each one runnable, each one adding a guarantee the step before did not have.
By the end of this page you will have a reader that turns a PDF into rows, drops the total line without knowing the word total, sets its own arithmetic against what the document declares, and refuses rather than hand over a table it does not believe.
Every step runs. Copy them in order into one file.
The document we are reading
A statement, five operations and a total:
02/05/2026 CARTE AMAZON 12,40
03/05/2026 VIR SEPA LOYER 750,00
05/05/2026 CARTE SNCF 68,00
09/05/2026 PRLV EDF 91,32
12/05/2026 CARTE BOULANGER 7,90
TOTAL DES DEBITS 929,62
Note the last line: no date, and a figure that is the sum of the others. Everything below turns on those two facts.
Step 1 — open it, and get cells
import { openDocument, cellsOf } from 'truecopy';
const document = await openDocument(file);
const rows = document.pages.flatMap((page) => cellsOf(page));
openDocument is the only way in, and it forces what an interface cannot: a size cap, a page cap, a deadline, and the engine released after use. A read that never returns leaves a screen on “reading the file…” for ever, with no button and no way out.
Step 2 — say what a cell is
The library knows nothing about your documents, and that is deliberate: one that shipped the meaning would make the next project describe its documents in a vocabulary designed for somebody else’s.
import { readDate, isOnlyNumber } from 'truecopy/notation';
const NOTATION = { dateOrder: 'DMY' } as const;
const kindOf = (cell: string) => {
if (readDate(cell, NOTATION)) return 'date';
// Two decimals is what tells money from a reference number. That is YOUR
// knowledge, and it stays in your code.
if (isOnlyNumber(cell, 2)) return 'amount';
return 'text';
};
readDate and isOnlyNumber come from notation, which reads how a page writes a figure and never what it means. That 1 234,50 and 1,234.50 are the same quantity says nothing about banking.
Step 3 — let the table describe itself
import { findRowAnomalies, thresholdsFor } from 'truecopy/signature';
const SIGNATURE = {
kindOf,
thresholds: {
...thresholdsFor(['amount', 'text'], 0.6),
// A date column nearly always filled, with one hole, is a total line:
// it has no date because it is not an operation.
date: { share: 0.6, emptyIsAnomalyAbove: 0.7 }
}
};
const anomalies = findRowAnomalies(rows, SIGNATURE) ?? rows.map(() => undefined);
anomalies[5] now says { cause: 'empty', column: 0, kind: 'date' }. The total line is marked, and no word was recognised — which is why this survives an issuer whose totals are written in a language you never planned for.
findRowAnomalies returns null below five rows: too few to learn a shape from, and saying nothing beats learning from nothing.
Step 4 — a reading, and what the document says about itself
A reading returns two things. The records, and the header — what the document declares without being a record.
import { readNumber } from 'truecopy/notation';
const read = (document) => {
const rows = document.pages.flatMap((page) => cellsOf(page));
const anomalies = findRowAnomalies(rows, SIGNATURE) ?? rows.map(() => undefined);
const records = [];
let declaredTotal = null;
rows.forEach((cells, index) => {
const amount = readNumber(cells[2] ?? '');
if (anomalies[index]) {
// The row outside the shape is the total, and the total is what the
// reading will be checked against.
if (amount !== null) declaredTotal = amount;
return;
}
const date = readDate(cells[0] ?? '', NOTATION);
if (date && amount !== null) records.push({ date, label: cells[1], amount });
});
return { records, header: { declaredTotal } };
};
Without that second field the self-check has no raw material. It is the difference between a reading you hope is right and one the document confirms.
Step 5 — make the document confirm it
const reader = {
read,
selfCheck(_document, reading) {
if (reading.header.declaredTotal === null) {
return { nothing: 'this statement announces no total' };
}
return {
declared: [reading.header.declaredTotal],
read: reading.records.reduce((sum, row) => sum + row.amount, 0),
unit: 'EUR'
};
},
rowsToReview(document) {
// The DOCUMENT, not the reading: a correction screen has to show the rows
// that were DROPPED, and those are not in the reading by definition.
const rows = document.pages.flatMap((page) => cellsOf(page));
const anomalies = findRowAnomalies(rows, SIGNATURE) ?? rows.map(() => undefined);
return rows.map((cells, index) => ({
raw: cells.join(' | '),
fields: { date: cells[0], label: cells[1], amount: cells[2] },
droppedBecause: anomalies[index] && describeAnomaly(anomalies[index])
}));
}
};
Three methods, and only three. repair and refuse are optional, and their defaults err the only direction a default may err in — toward refusing. Five methods before anything runs is a wall, and a wall in front of an interface gets return null written five times.
Step 6 — drive it
import { readDocument } from 'truecopy/contract';
const result = readDocument(document, reader);
result.verdict; // 'read'
result.selfCheck; // { declared: [929.62], read: 929.62, unit: 'EUR' }
result.discrepancy; // null
result.rowsToReview; // six rows, the last one marked dropped
Change one amount in the document and the verdict becomes needs-review, with a discrepancy saying by how much. That is the whole point: a reading that contradicts its document never comes back as sound.
What you have now, that you did not have at step 1
| A total line dropped | without a list of forbidden words |
| An arithmetic check | that depends on no layout |
| A correction screen | that can show the rows that were dropped |
| A verdict | that cannot quietly say “fine” when it is not |
Make it stay true
A reader is written once and edited for years. The conformance kit turns the six rules above into assertions in your own test suite, against a corpus of your own documents:
import { checkContract, failures } from 'truecopy/kit';
it('holds the reading contract', async () => {
const results = await checkContract(reader, myCorpus, {
referencePdf: pdfWithText([{ word: '2018', x: 50, y: 700 }]),
open: openLikeTheApp,
foreign: [{ name: 'a payslip', document: aPayslip }]
});
expect(failures(results)).toEqual([]);
});
An interface is dodged with a return null. An assertion is not.