Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,16 @@

All notable changes to this package are listed here. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and the versions follow [Semantic Versioning](https://semver.org/).

## [Unreleased]

### Fixed

- `11.482 G @ $3.299`, with a currency mark between the `@` and the price, is read as the price per gallon.
- A price in pence or cents printed with no mark after `@` (`40.00L @ 169.9`, in Australia, Canada and the UK) is read as 1.699 when that closes the sum, and kept as printed when it does not.
- `30.00 LTR 155.9 P/LTR` keeps its quantity. The `00 LTR 155` in it was being read as a date.
- `PPL 155.9` and `P/LTR 155.9`, with the pence mark heading the price, are read as the price per litre.
- On a receipt whose total includes a car wash or a snack, the amount labelled as the fuel's (`FUEL 77.99`, or a fuel name) checks the fuel when the total does not.

## [1.1.0] - 2026-10-31

### Added
Expand Down
24 changes: 20 additions & 4 deletions src/extract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,18 @@ const LABEL_REACH = 64;
* The label nearest a number: what precedes it, else the column heading above
* it, else the start of its line.
*/
/**
* The amount for the fuel alone, on a receipt that also sells a car wash or a
* snack: `FUEL 77.99`, `UNLEADED 77.99` above `TOTAL 85.99`.
*/
function namesFuelAmount(label: string): boolean {
return (
/(?<![a-z])(?:fuel|gas|combust[ií]vel|combustible|carburant|kraftstoff|carburante)\s*[:.]?\s*$/i.test(
label.trim(),
) || looksLikeProduct(label)
);
}

function contextFor(token: NumberToken, lines: readonly OcrLine[]): string {
const before = token.before.trim();
if (token.heading && !/\p{L}/u.test(before)) return token.heading;
Expand Down Expand Up @@ -151,6 +163,7 @@ function priorFor(
/[x×*@]\s*\p{Sc}?\s*\d[\d.,]*\s*(?:[^\d\s]+\s*\/\s*\S+)?\s*$/u.test(label)
)
score += 0.4;
if (namesFuelAmount(label)) score += 0.4;
if (/subtotal|sub total|netto|neto|liquido|base/i.test(label)) score -= 0.6;
}
if (score < 0.35) return -Infinity;
Expand Down Expand Up @@ -234,6 +247,7 @@ function balances(
}

function solve(
lines: readonly OcrLine[],
quantities: readonly Candidate[],
prices: readonly Candidate[],
totals: readonly Candidate[],
Expand All @@ -254,16 +268,18 @@ function solve(
for (const p of prices) {
if (samePlace(q.token, p.token)) continue;
// A receipt with a shop item too totals more than the fuel. The
// amount printed on the fuel line itself is the pump's own sum, so
// it may close the triple when the receipt's total does not.
// amount printed on the fuel line itself, or labelled as the fuel's,
// is the pump's own sum, so it may close the triple when the
// receipt's total does not.
const t = closes(q, p, printedTotal)
? printedTotal
: (totals.find(
(c) =>
!c.implied &&
!c.token.alternate &&
(c.token.lineIndex === q.token.lineIndex ||
c.token.lineIndex === p.token.lineIndex) &&
c.token.lineIndex === p.token.lineIndex ||
namesFuelAmount(contextFor(c.token, lines))) &&
closes(q, p, c),
) ?? printedTotal);
const balanced = closes(q, p, t);
Expand Down Expand Up @@ -605,7 +621,7 @@ export function extractReceipt(
const quantities = candidatesFor("quantity", tokens, lines, fallbackCurrency);
const prices = candidatesFor("unitPrice", tokens, lines, fallbackCurrency);
const totals = candidatesFor("total", tokens, lines, fallbackCurrency);
let solution = solve(quantities, prices, totals, fallbackCurrency);
let solution = solve(lines, quantities, prices, totals, fallbackCurrency);
// The sum closes on more than one reading of the same paper. Only the tank
// can say which is a fill; without it, none of them counts as checked.
if (solution.rivals.length > 0) {
Expand Down
37 changes: 28 additions & 9 deletions src/numbers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -127,12 +127,20 @@ const SUBUNIT_RATE = new RegExp(
`^\\s*(?:p|c|ct|cts|¢)(?:\\s*\\/\\s*(${LITRE}|${GALLON})|(pl))?(?![a-z0-9])`,
"i",
);
/** The same mark heading its value: `PPL 155.9`, `P/LTR 155.9`. */
const SUBUNIT_BEFORE = new RegExp(
`(?<![a-z])(?:ppl|[pc]\\s*\\/\\s*(${LITRE}))\\s*[:.]?\\s*$`,
"i",
);
const PRICE_POSITION = new RegExp(
`(?:[@x×*]|(?<![a-z])(?:${PRICE_WORD})[^\\d]{0,12})\\s*$`,
"i",
);
/** `x 1,899` on a fuel line: a rate by position, with no unit printed at all. */
const TIMES_BEFORE = /(?:^|[\s\d])[x*×@]\s*$/i;
/** `x 1,899` or `@ $3.299` on a fuel line: a rate by position, with no unit printed at all. */
const TIMES_BEFORE = new RegExp(
`(?:^|[\\s\\d])[x*×@]\\s*(?:${CURRENCY_PATTERN})?\\s*$`,
"i",
);

const UNIT_AFTER = new RegExp(
`^[\\s]*(?:(${LITRE})|(${GALLON}|g(?=\\s*[@x×*]))|(${KWH})|(${CURRENCY_PATTERN})|(%)|(km|kms|mi|mls|miles)|(${MASS})|(${GAS_VOLUME}))(?![a-zA-Z])`,
Expand Down Expand Up @@ -423,22 +431,33 @@ function tokensIn(
const after = text.slice(to);
const subunit = SUBUNIT_RATE.exec(after);
const hundredths =
subunit && (subunit[1] || subunit[2] || PRICE_POSITION.test(before))
(subunit && (subunit[1] || subunit[2] || PRICE_POSITION.test(before))
? subunit
: null;
: null) ?? SUBUNIT_BEFORE.exec(before);
const unit = hundredths
? rateKind(hundredths[1] ?? "l")
: unitFor(before, after);
// `40.00L @ 169.9` prints pence or cents with no mark at all. The
// price in whole currency is offered as an implied reading, so it is
// believed only when it closes the sum.
const unmarkedHundredths =
!hundredths &&
unit === "per-unit" &&
/^\d{2,3}[.,]\d$/.test(repairedText) &&
values.length === 1;
tokens.push({
readings: hundredths
? values.map((value) => Math.round(value * 1e4) / 1e6)
: values,
implied,
: unmarkedHundredths
? [values[0], Math.round(values[0] * 1e4) / 1e6]
: values,
implied: unmarkedHundredths ? 1 : implied,
lineIndex,
box: boxForRange(line, from, to),
before: before.toLowerCase(),
after: after.toLowerCase(),
currency: lineCurrency,
unit: hundredths
? rateKind(hundredths[1] ?? "l")
: unitFor(before, after),
unit,
repaired,
partOfDateTime: overlapsAny(stamps, from, to),
glued: /[a-zà-ÿ]$/i.test(before) || /^[a-zà-ÿ]/i.test(after),
Expand Down
3 changes: 2 additions & 1 deletion src/patterns.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,8 @@ export const DATE_PATTERNS: readonly RegExp[] = [
// 01-08-2026, 01/08/26, 1.8.2026
/\b(\d{1,2})[-/.](\d{1,2})[-/.](\d{2,4})\b/g,
// 01 AGO 2026, 1 Aug 26, 01-SEP-2026
/\b(\d{1,2})\.?[\s-]([a-zà-ÿ]{3,12})\.?[\s-](\d{2,4})\b/gi,
// (never the decimals of a number: `30.00 LTR 155.9` is no date)
/(?<![\d.,])(\d{1,2})\.?[\s-]([a-zà-ÿ]{3,12})\.?[\s-](\d{2,4})\b(?![.,]\d)/gi,
// AUG 01, 2026
/\b([a-zà-ÿ]{3,12})\.?\s(\d{1,2}),?\s(\d{4})\b/gi,
];
Expand Down
82 changes: 82 additions & 0 deletions test/world.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,9 +84,91 @@ describe("prices written in hundredths", () => {
);
expect(r.unitPrice?.value).toBe(1.499);
});

it("reads pence or cents printed with no mark after @", () => {
for (const [country, mark] of [
["AU", "$"],
["CA", "$"],
["GB", "£"],
]) {
const r = read(["Ampol", "E10", "40.00L @ 169.9", `TOTAL ${mark}67.96`], {
country,
});
expect(summary(r)).toEqual({
quantity: 40,
unitPrice: 1.699,
total: 67.96,
checked: true,
});
}
});

it("keeps an unmarked price as printed when hundredths do not close", () => {
const r = read(["40.00L @ 169.9", "TOTAL £50.00"], { country: "GB" });
expect(r.unitPrice?.value).toBe(169.9);
expect(r.arithmeticChecked).toBe(false);
});

it("reads LTR and P/LTR on one line", () => {
const r = read(
["BP", "UNLEADED", "30.00 LTR 155.9 P/LTR", "TOTAL £46.77"],
{
country: "GB",
},
);
expect(summary(r)).toEqual({
quantity: 30,
unitPrice: 1.559,
total: 46.77,
checked: true,
});
expect(r.date).toBeNull();
});

it("reads a PPL heading before the price", () => {
const r = read(
["BP", "UNLEADED", "LITRES 30.00", "PPL 155.9", "TOTAL £46.77"],
{
country: "GB",
},
);
expect(summary(r)).toEqual({
quantity: 30,
unitPrice: 1.559,
total: 46.77,
checked: true,
});
});
});

describe("a US pump", () => {
it("reads a $ between @ and the price", () => {
const r = read(["Shell", "UNLEADED", "11.482 G @ $3.299", "TOTAL $37.88"], {
country: "US",
});
expect(summary(r)).toEqual({
quantity: 11.482,
unitPrice: 3.299,
total: 37.88,
checked: true,
});
});

it("checks the fuel amount when the total includes a car wash or a snack", () => {
for (const extra of ["CAR WASH 8.00", "SNACKS 8.00"]) {
const r = read(
["Shell", "23.640 G @ 3.299", "FUEL 77.99", extra, "TOTAL $85.99"],
{ country: "US" },
);
expect(summary(r)).toEqual({
quantity: 23.64,
unitPrice: 3.299,
total: 77.99,
checked: true,
});
}
});

it("joins the tenth of a cent printed small", () => {
for (const price of ["PRICE/GAL $3.45 9", "PRICE/GAL $3.45⁹"]) {
const r = read(["Shell", "GALLONS 12.345", price, "FUEL TOTAL $42.70"], {
Expand Down
Loading