truecopy1.0.0

Quickstart

Two lines for rows and cells, and the one field that separates this from an extractor. Then the three steps that turn a reading into one that checks itself.

Before you install anything

npx truecopy a-statement.pdf

It prints the cut it made into columns, what share of rows each column fills, the rows themselves — and then, on its own, what the reading could not vouch for. Thirty seconds, no project, no code. If it makes nothing of your documents, you have lost nothing.

Install

npm install truecopy

Node 20 or later, ESM only, MIT. pdfjs-dist is an optional peer: install it only if you read PDFs.

npm install pdfjs-dist   # PDFs only. A paste or a CSV needs nothing.

Rows and cells, in two lines

import { readTable } from 'truecopy';

const { rows, warnings } = await readTable(file);

rows is string[][] — every row of every page, cut into cells. If that is all you want, destructure it and go.

file is whatever was dropped: a PDF, a table pasted out of one, a CSV, a TSV. One engine reads all of them — the cut votes on the left edges that come back row after row, and that question does not care whether they were measured in points, in characters or in field indices. Where each one starts, and what the reading prints when it could not cut at all.

warnings is why this is not just another extractor:

// []
// or
// ['column 3 of page 2 is filled on only 8% of its rows - the cut may have invented it']

An empty warnings is not a promise that the reading is right. It means nothing looked wrong from the shape of the page, which is a much smaller claim. Every warning is computed without knowing anything about your document.

In a browser

pdf.js refuses to start in a browser without a worker, and truecopy does not resolve that URL for you — every bundler spells it differently, and choosing one would lock you into it.

// Vite
import workerSrc from 'pdfjs-dist/legacy/build/pdf.worker.mjs?url';
const { rows } = await readTable(file, { workerSrc });

In Node you can leave it out: pdf.js then runs inline, which is slower on a long document and correct everywhere.

When the rows have to be trusted

Two lines are enough while nothing acts on the result. The moment something does — a budget, a report, a decision — the question changes from did I get rows to are these the right rows. Three steps, in order of what they cost you.

1. Say what a cell is

The library has never heard of a date or an amount. You name the kind; it counts, divides and compares.

import { findRowAnomalies, thresholdsFor } from 'truecopy/signature';

const signature = {
  kindOf: (cell: string) =>
    isDate(cell) ? 'date' : isAmount(cell) ? 'amount' : 'text',
  thresholds: {
    ...thresholdsFor(['amount', 'text'], 0.6),
    date: { share: 0.6, emptyIsAnomalyAbove: 0.7 }
  }
};

const anomalies = findRowAnomalies(rows, signature);

Now the total line, the balance line and the footer come back marked — without any list of forbidden words. A table describes itself, and a row that breaks what every other row does is not a row of the table.

2. Set the reading against the document

Most documents announce something about themselves: a statement carries opening + Σ = closing, a career record announces its total. That is the only check that depends on no layout at all.

selfCheck(document, reading) {
	if (reading.header.declaredTotal === null) {
		return { nothing: 'this document announces no total' };
	}
	return {
		declared: [reading.header.declaredTotal],
		read: reading.records.reduce((sum, row) => sum + row.amount, 0),
		unit: 'EUR'
	};
}

Saying { nothing } costs a sentence, on purpose: an oversight becomes something written down instead of a silent null.

3. Drive it through the contract

import { readDocument } from 'truecopy/contract';

const result = readDocument(document, myReader);
// result.verdict: 'read' | 'needs-review' | 'refused'

The one rule the pipeline enforces: a reading that contradicts its document never comes back as read. You choose between refusing and asking for review; you cannot choose to say nothing.

Where to go next