# Tutorial - a reader that checks itself Source: https://truecopy.dev/docs/tutorial/ Build a complete statement reader from an empty file. It takes six steps. Each one runs, and each one adds 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 ```ts 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. ```ts 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](https://truecopy.dev/docs/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 ```ts 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. ```ts 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 ```ts 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 ```ts 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](https://truecopy.dev/docs/kit/) turns the six rules above into assertions in your own test suite, against a corpus of your own documents: ```ts 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. Map of this site for a model: https://truecopy.dev/llms.txt Every page in one file: https://truecopy.dev/llms-full.txt