truecopy1.0.0

layout - the cut, and where every value came from

truecopy/layout

Pure geometry over plain data. Three ways to find columns, and the coordinates that let a person point at a value instead of hunting for it.

No engine, no bytes, no clock. Every function here takes what it is given and returns something new — which is why the whole reading can be exercised without a PDF and without a browser.

A page as a table of cells

import { cellsOf } from 'truecopy/layout';

const rows = cellsOf(page); // string[][]
const rows = cellsOf(page, myBoundaries); // cut against your own lines

profileColumns, findRowAnomalies and explainRows all want exactly this shape.

Three ways to find the columns, and when each is right

boundariesFromRecurrence(rows, minimumSupport?, gap?)

The one to reach for. Keep only the x that come back row after row.

The spread of x over a whole page counts the letterhead, the address block, the footer and every word inside a long description. Measured on a real statement: twelve columns where the table has five, and a cut that wrong takes every judgement downstream with it.

What separates a column from furniture needs no knowledge of the document at all — a real column’s left edge recurs; a word in the middle of a description does not.

A weak band at either edge is kept: a column used twice in forty is a lone credit and is real, while a lone x in the middle is a label that drifted.

gap is how far apart two x have to be to open two columns, in the unit they were measured in — see the rulers below. Left out, the PDF value is used.

boundariesFromAnchors(anchors)

When the page gives you something you trust — the x of a header label, a rule drawn on the page, a cut a person confirmed. Each boundary falls halfway between two anchors, in the same shape columnAt and rowToCells already read, so a reader swaps one for the other and changes nothing else.

columnBoundaries(items, gap?)

The raw spread of x over everything given. It knows nothing and works on any page, which is its strength and its limit. pageFrom uses it, so page.columnBoundaries is always this one.

Page-wide, never row-wide. Deciding “column or not” on the gap between two neighbouring items glues a cell to the next as soon as they happen to touch on that row — and that is how a whole career disappeared into 20130 trim.

One engine, three rulers

Nothing above needs an x to be a point. The cut asks which left edges come back row after row, and that question is the same whichever ruler measured them — which is how the same code reads a page, a paste and a CSV.

TextPage.unit names the ruler, and gapFor(unit) turns it into the one number the cut needs.

CoordinateUnit x is gapFor
points a PDF’s own unit, origin bottom-left (default) 18
characters the character a field starts at 2
index the field’s position in the line 0

characters is two and not one because a column of amounts is usually flush right: its values start one or two characters apart and are still one column. index is zero because x is the column there — any two different indices are two columns, and nothing lies between them.

A page with no unit came from a PDF, which is the only ruler this library had before text got one.

Where every value came from

import { cellsOf, placesOf } from 'truecopy/layout';

const cells = cellsOf(page); // cells[3][1] is what the fourth row holds
const places = placesOf(page); // places[3][1] is where to find it
// { page: 1, x: 430, y: 700, width: 30 }

The two are laid out identically, so they are read together. A cell nothing fell into has no place — null, not a rectangle of nothing.

This is what makes a correction screen usable. Handed a list of figures, a person hunts through the document again; handed a place, they jump to it. ReviewableRow.where carries the same thing for the rows a person is asked to confirm.

No height, because none is known: a text item carries no height here, and inventing a line spacing would draw a rectangle the document never had. Your renderer knows its own line box better than this library does.

placeOf(items, page) is the same question asked of any group of items — a row is all of them, a cell is the ones that fell into one column.

Rows

rowsFrom(items, boundaries) groups items by proximity of baseline, never by rounding. Rounding cuts space into fixed buckets, and two items a tenth apart land in different buckets whenever they straddle a bucket edge — so a row of the table splits in two, at the mercy of where it happened to sit on the page.

Joining a row puts one tab between columns and nothing inside one. The “nothing” matters as much as the tab: the two halves of an amount are two items of the same cell, and separating them turns one number into two.

Assembling a document

  • pageFrom(number, width, height, items) — items into a page, cut and rowed.
  • documentFrom(pages, origin, name) — pages into a document.
  • documentFromText(text, name) — a paste, a CSV, an export. One page, because a paste was never paginated, and one row per line with blank lines dropped. It picks its own ruler: the field’s index when a delimiter recurs line after line, the character otherwise. Prose gives one field per line, and the row comes back whole.