truecopy1.0.0

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.