readTable - the two-line path

truecopy/table

Rows and cells out of a file with no configuration at all, plus the field that says what the reading could not vouch for.

import { readTable } from 'truecopy/table';

const { rows, warnings, boundaries, document } = await readTable(file, options?);
Field
rows string[][]: every row of every page, cut into cells
warnings string[]: what could not be vouched for
boundaries number[][]: the cut used, one list per page
document the Document, for a reader that needs more than the cells

options is OpenOptions: caps, deadline, workerSrc, pdfjs.

Whatever was dropped, the same two lines

file is a PDF, a table pasted out of one, a CSV, a TSV, an OCR page. One engine reads all of them, because the cut votes on which left edges come back row after row, and that question does not care what measured them. A document only has to say where its fields start.

What was dropped Where a column starts What the reading prints
a PDF the item’s x, in points cut at 100, 290
a table pasted with spaces the character the field starts at cut at characters 7, 22
a file written with a delimiter the field’s index: a CSV lines nothing up cut on the delimiter
prose, or a file that quotes its fields nowhere every row came back whole

Tab, semicolon, pipe and comma, in that order, and only when the count recurs line after line. What makes a delimiter real is what makes a column real.

Two refusals come with that, and both are the point rather than a gap:

  • A file that quotes its fields is not split. A quoted field may hold the delimiter itself, and splitting anyway shifts every column after it. Half-parsing a CSV is exactly the plausible-but-wrong reading this library argues against, so the rows come back whole with a warning.
  • An aligned paste is not read as a CSV because its amounts hold commas. Every French amount does, so counting commas finds exactly one on every line of a pasted statement, and cutting on it would turn 12,40 into two columns. Alignment wins; only a tab overrides it, a tab never being punctuation.

warnings is the whole point

Whoever types “extract tables from pdf” wants rows, and making them write a kindOf and choose thresholds before the first result is twenty minutes most people do not spend. So this is two lines.

What it does not do is return a bare array. A plausible-looking table out of a document that was misread is worse than nothing, because nobody checks a table that looks right. Hand one over with no signal and this library becomes the thing it was written to prevent, and a worse one than the extractors that have spent years tuning their heuristics.

An empty warnings is not a promise. It says nothing looked wrong from the shape of the page. That is a much smaller claim than “this reading is right”, and the distance between the two is what the rest of the library is for.

Every warning is computed without knowing anything about your document:

  • page N carries no text at all: a blank page, a scan, an image;
  • page N shows no column at all: the page is prose, or the cut failed and every row came back whole;
  • column C of page N is filled on only X%: the cut may have invented that column out of a letterhead;
  • the pages disagree on how many columns there are: usually a different table, and joining them makes a third that is neither.

boundaries, and why they are not the page’s own

readTable does not cut on page.columnBoundaries. That one is the spread of x over everything printed (letterhead, address block, footer), and on a real page it proposes columns the table never had: measured, twelve where the table has five.

It uses boundariesFromRecurrence instead: keep only the x that come back row after row, because a real column’s left edge recurs and a word in the middle of a description does not.

boundaries is handed back so you can explain or re-cut the same page against the same lines:

import { explainDocument } from 'truecopy/explain';

const { document, boundaries } = await readTable(file);

console.log(
  explainDocument(document, {
    boundariesOf: (page) => boundaries[page.pageNumber - 1]
  })
);

Without that, the heading and the cells would tell two different stories.

When two lines are not enough

The moment something downstream acts on the rows (a budget, a report, a decision), you want a reading that checks itself rather than one that merely looks fine:

  • findRowAnomalies drops the rows that break the table’s own shape;
  • validate says whether enough well-formed records came back;
  • readDocument refuses to return a reading that contradicts its document.