notation - how a page writes a figure

truecopy/notation

That 1 234,50 and 1,234.50 are the same quantity says nothing about banks. It says how the page was typeset. Everybody rewrites that part, and everybody gets it wrong once.

Notation is not domain. That (123,45) is negative and so is 123,45-, that 25/01 is a day before a month here and after it there: none of it says anything about banking, pensions or invoices.

import {
  readNumber,
  decimalMarkOf,
  findNumbers,
  isOnlyNumber,
  readDate,
  readLeadingDate
} from 'truecopy/notation';

Numbers

readNumber('1 234,56'); //  1234.56
readNumber('1,234.56'); //  1234.56
readNumber("1'234.56"); //  1234.56   Swiss apostrophe
readNumber('1 234,56 €'); //  1234.56   currency, thin spaces
readNumber('27800.50'); //  27800.5   NOT 2 780 050
readNumber('27.800'); //  27800     three digits behind is thousands
readNumber('1.2'); //  1.2       one or two digits behind is a decimal
readNumber('(123,45)'); //  -123.45   the accounting negative
readNumber('123,45-'); //  -123.45   the trailing minus
readNumber('no figure'); //  null

Null rather than a guess. On figures somebody will act upon, a refusal costs a correction and a wrong reading costs a decision.

The two defects this file exists to hold

Both were written twice, in two different readers, and both shipped:

  1. Every dot read as a thousands separator turned 27800.50 into 2 780 050, a hundred times over, on a document where the figure is compared against a legal threshold.
  2. A run of spaces bounded too tightly after an opening bracket made the match start later and drop the bracket, turning an accounting negative into a positive amount, silently, on financial data.

Both are the kind of defect that passes every test written by whoever caused it. They live in one place now, with the tests that pin them.

The one question a token cannot answer

1,234 is one thousand two hundred and thirty-four on a Luxembourg page and one point two three four on a Paris one. Nothing in those five characters decides which; only the document does.

decimalMarkOf(text); // ',' | '.' | null   a DecimalMark, or nothing settled it

readNumber('1,234'); //  1234     the count above decides: a guess, often right
readNumber('1,234', decimalMarkOf(text)); //  the document decides

null is accepted and means the same as leaving the argument out, so the two compose with no word in between: readNumber(raw, decimalMarkOf(text)) is the whole idea, and a document that settled nothing must not force you to write it differently.

Three kinds of evidence, none of which knows what a number means: a token carrying both marks (the rightmost is the decimal one), a mark used more than once (it groups thousands), and a mark followed by anything but three digits (a group of thousands is exactly three). It is the column cut’s question asked of numbers - a page is read by what recurs on it, not by what one token looks like.

null is an answer, and the honest one. A report translated into another language keeps its figures and changes their punctuation; guessing there corrupts them silently.

Finding numbers in a line

findNumbers('2018 worked 4 quarters, 28 500,00 paid');
// [{ value: 2018, raw: '2018', index: 0 }, { value: 4, … }, { value: 28500, … }]

findNumbers('2018 worked 4 quarters, 28 500,00 paid', 2);
// [{ value: 28500, … }]   only figures written with two decimals

The decimals argument is how a page tells a sum of money from a reference number without the library ever hearing the word “amount”. That two decimals mean money is your knowledge; it stays in your code.

The lookarounds keep a match from starting inside another number. Without them a date glued to a figure (30/05/2026 300,00 on a flattened line) reads as 026 300,00.

isOnlyNumber('1 234,56'); // true   a cell of figures
isOnlyNumber('paid 12,00'); // false  prose that quotes one
isOnlyNumber('12', 2); // false  no fractional part

Is this number grouped the way its document groups thousands

readNumber throws the separators away, which is right for reading one number and wrong for deciding where two of them meet.

readNumber('000 106 236 000,00', ','); // 106236000   a perfectly good figure
wellGrouped('000 106 236 000,00', ','); // false       no group begins with a zero
wellGrouped('1 300', ','); // true

That is the shape of a silent lie. A cut made inside a number leaves 1 300 where the report prints 1 300 000; the value still closes its sector, so the arithmetic never catches it, and a quantity nothing cross-checks is precisely where a wrong figure survives. Measured on a real fund report before this existed.

The rule is the one every typesetter follows and none writes down: the first group is one to three digits and does not begin with a zero, every group after it is exactly three, and one group alone passes whatever its length - a quantity is often printed with no separator at all. The argument is the decimal mark, which is what decimalMarkOf returns: the thousands separator is a space in French notation and the other mark in English, and both follow from that one answer.

Comparing a label a producer typed its own way

accentFree('Décembre'); // 'decembre'
accentFree('DECEMBRE'); // 'decembre'

Lower-cased and stripped of accents, so a table of month names does not have to carry every spelling of one word. It is exported because it is not only that table’s business: PDF producers disagree about accents, so a prescribed heading arrives with them on one file and without them on the next, and any label read out of a document has to be folded before it is compared.

What it deliberately does not do is fold anything but accents. The eszett, the Turkish dotless i and the Nordic letters are different letters, and a caller who needs one folded knows something about its language that this library does not.

Dates

const FRENCH = { dateOrder: 'DMY', months: { janv: 0, fevr: 1, mars: 2 } };

readDate('01/02/2026', FRENCH); // 1 February
readDate('01/02/2026', { dateOrder: 'MDY' }); // 2 January
readDate('31/04/2026', FRENCH); // null - a 31st in a 30-day month is a misread

There is no reading of 01/02/2026 that is right everywhere, and no amount of cleverness makes one: it is stated, or it is guessed.

readLeadingDate('25/01/2026 GROCERIES', FRENCH);
// { date, length: 10 }  - so you strip it and keep the wording

readLeadingDate('25/01 GROCERIES', FRENCH, 2026); // the year the document states
readLeadingDate('25/01 GROCERIES', FRENCH); // null - nothing says which year
readLeadingDate('25 janv. 2026 …', FRENCH); // months come from the notation

months is data, keyed accent-free and lower-case, so a profile served by a backend can carry it, the same argument pattern makes for everything that varies by market.