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 - and it is the part everybody rewrites and everybody gets 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,
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:
- Every dot read as a thousands separator turned
27800.50into2 780 050, a hundred times over, on a document where the figure is compared against a legal threshold. - 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.
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
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.