labels - which cells could be the value a label announces

truecopy/labels

A label and its figure are scattered by the layout, not by the domain. Three applications wrote the same search three times, in three geometries, with no domain word in any of them.

import { labelledValues, columnOfHeader } from 'truecopy/labels';

const found = labelledValues(rows, isLabel, isValue, options?);
// [{ label: { row, column }, values: [{ row, column, raw, distance }, …] }, …]

contract states the need in as many words and answered none of it: SelfCheck.declared is a list because a document may announce several candidate values when its layout scattered a label from its number. Three applications wrote that search separately - four lines under a heading, the first amount to its right, a column index read out of a header cell. Same question, three geometries, and no word about banking, property or pensions in any of them.

It will not pick

values comes back closest first, and empty when the document offers none, which is an answer of its own: a label with no value beside it is worth saying out loud.

Handing back one value would decide between two readings of a layout. The list feeds SelfCheck.declared and readDocument keeps the one that fits, so the document decides rather than a rule about how far a number usually sits from its heading.

isLabel and isValue are yours, and that is the whole line this library holds: it does not know a total from a heading from a footnote, and a list of words meaning “total” would be a domain shipped inside a parser.

labelledValues(
  rows,
  (cell) => accentFree(cell).startsWith('total'),
  (cell) => isOnlyNumber(cell, 2)
);

A search stops at the next label

One rule costs a reading when it is missing, and it was met on a real pension record: two headings printed close together let the second one’s figure count for the first as well. A doubled total, a false proof, and a refusal on a reading that was right.

Option
reach how many cells to walk. Four by default, measured rather than chosen
look 'row', 'column' or 'both'. Both by default

Four clears the presentation prose and the form reference a pension record prints between a heading and its figure, without reaching the next section. Look says where: along the label’s own row to the right (Total 1 234,56), down its own column (a header, and its figures under it), or both merged and sorted by distance. A caller who knows its layout says so; one that does not gets every Candidate with its distance.

distance is counted in cells, never in points. A distance in points would say a wide column is farther than a narrow one, which is a fact about the typesetting and not about which value belongs to which heading.

raw is the cell exactly as the document prints it, never parsed. Reading it is your call, and it is one line: readNumber(raw, decimalMarkOf(document.text)).

The unit announced once, in the header

const surface = columnOfHeader(rows, (cell) => accentFree(cell).includes('(en m2)'));

A property schedule announces its unit once, in the header, and prints bare numbers underneath. Requiring the unit inside each cell returned no value at all on a real corpus; reading it from the header returned them.

null when no header matches, and null again when several do. Two matching headers is exactly the document nobody should read on a hunch: either the predicate is too loose or the table carries two of that column, and picking the first would be a silent answer to a question nobody asked.

The same question on prose, with no grid

labelledValues needs rows and columns. A caller holding an API field, an OCR pass or a text-layer dump has neither, and the question does not go away with the geometry: it was written a fourth time, by a fourth caller, and that caller got it wrong.

import { labelledSpans } from 'truecopy/labels';

const found = labelledSpans(
  notice,
  /exercice clos le \d{1,2} \p{L}+ \d{4}/giu,
  /\d{1,4},\d{2} euros/g
);
// [{ label: { index, raw }, values: [{ index, raw, distance }, …] }, …]

Same contract as its sibling, minus a dimension. You say what a label looks like and what a value looks like, the walk stops at the next label, and it hands back candidates rather than picking one. Each Labelling pairs one label with its candidates, exactly as Labelled does on a grid. A Span is an index and the raw text at it; a TextCandidate adds distance, counted in characters between the end of the label and the start of the value, zero meaning they touch.

SpanOptions carries one:

Option
reach how many characters to walk past a label. Unbounded by default

Unbounded is not laziness: in text the next label is the boundary, and a character budget would be a number nobody measured. The one case it guards is the last label of a document, which otherwise reaches to the end. A caller who knows its own layout narrows it.

A value inside its own label is not a candidate

The year in exercice clos le 31 decembre 2024 is part of the label, not the figure that label announces. Returning it would answer a question nobody asked, so an overlapping match is dropped.

What this was written against is one step further out, and it published a whole series wrong: a French dividend table prints the payment date between each exercice and its amount, so a caller walking from year to year let the payment year collect the amount. Every value in the series was well formed, and every one of them sat under the wrong year.