explain - see what the reading decided
truecopy/explain
The popular extractors have this feature, and the careful ones forget it. It prints text, not an image, so it goes into a terminal, a CI log, a bug report and a test.
The first thing you need when a reading comes out wrong is not a better parser. It is knowing which of three things broke:
- the document is odd,
- the cut into columns is wrong,
- or your rule is wrong.
Each calls for a different fix, and a library that only returns records leaves you guessing between them.
import { explainDocument } from 'truecopy/explain';
console.log(explainDocument(document, { signature, roles }));
truecopy - statement-2026-05.pdf
pdf, 1 page(s), 6 row(s)
page 1 - cut at 100, 290
3 column(s), 6 row(s)
col kind filled role
0 date 83% when
1 text 100% -
2 amount 100% money
1 02/05/2026 | CARTE 28/04 AMAZON EU> | 12,40
2 03/05/2026 | VIR SEPA LOYER MAI | 750,00
3 05/05/2026 | CARTE 03/05 SNCF CONN> | 68,00
4 09/05/2026 | PRLV EDF | 91,32
5 12/05/2026 | CARTE 11/05 LE BOULAN> | 7,90
6 ! | TOTAL DES DEBITS | 929,62 <- column 0 (date) is empty
Everything printed was already computed and already public. What was missing was somewhere to read it all at once, in the order the reading made its decisions.
The cut names its ruler
A list of numbers with no unit is a riddle. cut at 1, 2 on a CSV sent somebody looking for a defect that was not there: those were the indices of the fields, and they were right.
page 1 - cut at 100, 290 a PDF: points, and points go unlabelled
page 1 - cut at characters 7, 22 a table pasted with spaces
page 1 - cut on the delimiter a CSV: the columns ARE the fields
page 1 - no boundary nothing lined up
Points stay unlabelled because they are what a page has always been measured in. On a delimited file the numbers say nothing a reader does not already know, so the line names what happened instead.
Text, and not an image
It goes into a terminal, a CI log, a bug report, a test’s expected value. It needs no canvas, no headless browser and no dependency. An image needs a screen.
The options
signature |
naming cell kinds turns on the kinds, and the rows that break the shape |
roles |
what each column was taken to be |
boundariesOf |
the cut to read the page by, when it is not the page’s own |
cellsOf |
your own cut, row by row (the one being debugged is yours, not the library’s) |
page |
only this page |
maximumRows, cellWidth |
how much to print |
Left without a signature, the cut and the fill rates are still shown, and those are what a wrong cut shows up in first.
boundariesOf matters more than it looks: readTable hands back the cut it used, and without passing it the heading and the cells would tell two different stories. It may return undefined, and a page it returns nothing for keeps its own cut - so a caller holding a cut for some pages and not others does not have to invent one.
const { document, boundaries } = await readTable(file);
explainDocument(document, {
boundariesOf: (page) => boundaries[page.pageNumber - 1]
});
Explaining a READING, not a document
That last example is the whole reason explainReading exists.
const table = await readTable(file);
console.log(explainReading(table));
console.log(describeDoubts(table.warnings));
explainReading(table, options?): the readingreadTablereturned, with the cut it actually used.readTablecuts on what recurs rather than on the page’s own spread of x, so explaining its result by the page’s cut is what makes the heading and the cells disagree.describeDoubts(warnings): the doubts, in words. Including the two sentences an empty list gets - “nothing looked wrong from the shape of this page”, and “that is not the same as this reading is right”. They lived inside the command until 2.0.4; every surface that prints a reading prints these now - the command and the MCP server - so the sentence somebody is left with cannot depend on which one printed it.
The doubts are not appended by explainReading, so that a caller can put something of its own between the two and still finish on them.
Also here
explainRows(rows, options): the same, for a reader holding rows that never came from a page: a paste, a CSV, an OCR line.describeAnomaly(anomaly): one anomaly, in the words of somebody looking at the page:column 0 (date) is empty,column 2 is not amount.
A truncated listing that looks complete is how a missing row gets read as a row that is not there, so what is not printed is counted out loud: ... 4 more row(s).
Put it behind your refusals
A refusal with no evidence is a dead end: nobody can believe it or contradict it. One of the applications built on this library attaches explainRows output to every refusal that says “I did not read this correctly”, folded under the message: invisible to whoever does not care, and decisive for whoever opens a ticket.