Improve OCR extraction for pale dot-matrix statements
OCR enhancement chain (illumination normalize, CLAHE, gamma darkening) now always runs before Tesseract, with a conditional 1.5x upscale for narrow crops. Adds printed doc-title and PAGE X OF Y marker extraction.
This commit is contained in:
+213
-6
@@ -10,6 +10,7 @@ from __future__ import annotations
|
|||||||
import re
|
import re
|
||||||
import statistics
|
import statistics
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
import cv2
|
import cv2
|
||||||
import numpy as np
|
import numpy as np
|
||||||
@@ -22,13 +23,25 @@ MONTHS = {
|
|||||||
"october": 10, "nov": 11, "november": 11, "dec": 12, "december": 12,
|
"october": 10, "nov": 11, "november": 11, "dec": 12, "december": 12,
|
||||||
}
|
}
|
||||||
|
|
||||||
# Ordered most- to least-specific; first match wins.
|
# Ordered most- to least-specific; first match wins. Slash dates tolerate a
|
||||||
|
# single OCR-injected space after each separator ("01/ 31/ 2025" on TD
|
||||||
|
# dot-matrix statements).
|
||||||
_DATE_PATTERNS: list[tuple[re.Pattern, str]] = [
|
_DATE_PATTERNS: list[tuple[re.Pattern, str]] = [
|
||||||
|
# Statement period end ("PERIOD: FROM 01/01/2025 TO: 01/31/2025") — the
|
||||||
|
# canonical document date for a bank statement, and checked first so the
|
||||||
|
# first transaction row's date doesn't win.
|
||||||
|
(
|
||||||
|
re.compile(
|
||||||
|
r"\b(?:TO|AU)\s*:?\s+(\d{1,2})\s?/\s?(\d{1,2})\s?/\s?(\d{4})\b",
|
||||||
|
re.IGNORECASE,
|
||||||
|
),
|
||||||
|
"mdy",
|
||||||
|
),
|
||||||
(re.compile(r"\b(\d{4})[-/](\d{1,2})[-/](\d{1,2})\b"), "ymd"),
|
(re.compile(r"\b(\d{4})[-/](\d{1,2})[-/](\d{1,2})\b"), "ymd"),
|
||||||
(
|
(
|
||||||
re.compile(
|
re.compile(
|
||||||
r"\b(jan|feb|mar|apr|may|jun|jul|aug|sep|sept|oct|nov|dec)"
|
r"\b(jan|feb|mar|apr|may|jun|jul|aug|sep|sept|oct|nov|dec)"
|
||||||
r"[a-z]*\.?\s+(\d{1,2}),?\s+(\d{4})\b",
|
r"[a-z]*\.?\s+(\d{1,2})[.,]?\s+(\d{4})\b",
|
||||||
re.IGNORECASE,
|
re.IGNORECASE,
|
||||||
),
|
),
|
||||||
"month_name_dmy",
|
"month_name_dmy",
|
||||||
@@ -36,12 +49,35 @@ _DATE_PATTERNS: list[tuple[re.Pattern, str]] = [
|
|||||||
# Assumes North American MM/DD/YYYY (matches Home Depot / Metro / Petro-Canada
|
# Assumes North American MM/DD/YYYY (matches Home Depot / Metro / Petro-Canada
|
||||||
# style receipts this app was designed around). Revisit if you record
|
# style receipts this app was designed around). Revisit if you record
|
||||||
# receipts using DD/MM/YYYY formatting.
|
# receipts using DD/MM/YYYY formatting.
|
||||||
(re.compile(r"\b(\d{1,2})[/-](\d{1,2})[/-](\d{4})\b"), "mdy"),
|
(re.compile(r"\b(\d{1,2})\s?/\s?(\d{1,2})\s?/\s?(\d{4})\b"), "mdy"),
|
||||||
|
(re.compile(r"\b(\d{1,2})-(\d{1,2})-(\d{4})\b"), "mdy"),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
# OCR misreads the digit 0 as the letter O in dotted print ("O1/ 31/2025").
|
||||||
|
_O_BEFORE_DIGIT_RE = re.compile(r"[Oo](?=\d)")
|
||||||
|
_O_AFTER_DIGIT_RE = re.compile(r"(?<=\d)[Oo]")
|
||||||
|
|
||||||
_TOTAL_LINE_RE = re.compile(r"\btotal\b", re.IGNORECASE)
|
_TOTAL_LINE_RE = re.compile(r"\btotal\b", re.IGNORECASE)
|
||||||
_MONEY_RE = re.compile(r"\$?\s*(\d{1,4}\.\d{2})\b")
|
_MONEY_RE = re.compile(r"\$?\s*(\d{1,4}\.\d{2})\b")
|
||||||
|
|
||||||
|
# Line items: "DESC 12.34" / "1234567 DESC 12.34 H" (Costco tax code).
|
||||||
|
# Totals / tender / card boilerplate intentionally excluded so callers can
|
||||||
|
# itemize SKUs for split booking (kids vs home, etc.) in the taxes pipeline.
|
||||||
|
_ITEM_LINE_RE = re.compile(
|
||||||
|
r"^(?:(?:\d{4,14})\s+)?(.+?)\s+\$?\s*(\d{1,4}\.\d{2})\s*[A-Z]?\s*$",
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
_ITEM_SKIP_RE = re.compile(
|
||||||
|
r"\b("
|
||||||
|
r"sub\s*total|subtotal|total|hst|gst|pst|qst|tax|change|cash|debit|"
|
||||||
|
r"mastercard|visa|amex|interac|approved|thank|balance|save|"
|
||||||
|
r"account|card\s*number|auth|approval|reference|invoice|"
|
||||||
|
r"customer\s*copy|merchant\s*copy|amount\s*:|date/?time|"
|
||||||
|
r"no\s*return|signature|member|terminal|cashier"
|
||||||
|
r")\b",
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
|
||||||
# HH:MM(:SS)? 24-hour clock, as printed near the transaction date on most
|
# HH:MM(:SS)? 24-hour clock, as printed near the transaction date on most
|
||||||
# receipts (e.g. "03/20/23 15:25:35"). Distinct enough from other
|
# receipts (e.g. "03/20/23 15:25:35"). Distinct enough from other
|
||||||
# colon-free receipt numbers (barcodes, reference IDs) that the first match
|
# colon-free receipt numbers (barcodes, reference IDs) that the first match
|
||||||
@@ -90,11 +126,139 @@ _TAX_YEAR_TITLE_RE = re.compile(
|
|||||||
re.IGNORECASE,
|
re.IGNORECASE,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# "PAGE 3 OF 17" / "PAGE 3 DE 17" (bilingual Canadian statements). Dot-matrix
|
||||||
|
# print OCRs sloppily, so the digits also admit the classic confusables
|
||||||
|
# ("PAGE ll OF 17" for 11, "0F" for OF) and stray underscores/punctuation.
|
||||||
|
_PAGE_MARKER_RE = re.compile(
|
||||||
|
r"\bPAGE\s*[:.,]?\s*([0-9lIiO]{1,3})\s*(?:[O0Q]F|DE)[_.,:]?\s+([0-9lIiO]{1,3})\b",
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
_DIGIT_CONFUSABLES = str.maketrans({"l": "1", "I": "1", "i": "1", "O": "0", "o": "0"})
|
||||||
|
|
||||||
|
# Printed document titles worth carrying into the filename ("what is this?"),
|
||||||
|
# checked in order. Bank statements all say "TD Canada Trust" in the corner;
|
||||||
|
# the title line is what actually distinguishes a transaction history from a
|
||||||
|
# direct-deposit form.
|
||||||
|
_DOC_TITLE_PATTERNS: list[tuple[re.Pattern, str]] = [
|
||||||
|
(re.compile(r"DIRECT\s+DEPOSIT", re.IGNORECASE), "direct_deposit"),
|
||||||
|
(
|
||||||
|
re.compile(r"TRANSACTION\s+HISTORY|HISTORIQUE\s+DES\s+OP", re.IGNORECASE),
|
||||||
|
"transaction_history",
|
||||||
|
),
|
||||||
|
(re.compile(r"DEPOSIT\s+ACCOUNT\s+HISTORY", re.IGNORECASE), "transaction_history"),
|
||||||
|
(re.compile(r"ACCOUNT\s+STATEMENT|RELEV[EÉ]\s+DE\s+COMPTE", re.IGNORECASE), "statement"),
|
||||||
|
(re.compile(r"\bINVOICE\b|\bFACTURE\b", re.IGNORECASE), "invoice"),
|
||||||
|
(re.compile(r"NOTICE\s+OF\s+ASSESSMENT", re.IGNORECASE), "notice_of_assessment"),
|
||||||
|
(re.compile(r"VOID\s+CH(?:EQUE|ECK)", re.IGNORECASE), "void_cheque"),
|
||||||
|
]
|
||||||
|
|
||||||
|
# Letters only (Latin + accented) — used to judge whether a vendor token is
|
||||||
|
# a real word after ignoring OCR-injected punctuation ("PETRO-—CANADA").
|
||||||
|
_LETTERS_RE = re.compile(r"[^A-Za-zÀ-ÖØ-öø-ÿ]+")
|
||||||
|
_VOWELS = "aeiouyàâäéèêëîïôöùûü"
|
||||||
|
|
||||||
|
|
||||||
|
def extract_page_marker(text: str) -> tuple[int, int] | None:
|
||||||
|
"""(page_number, page_total) from a printed 'PAGE X OF Y', if present."""
|
||||||
|
match = _PAGE_MARKER_RE.search(text)
|
||||||
|
if not match:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
page = int(match.group(1).translate(_DIGIT_CONFUSABLES))
|
||||||
|
total = int(match.group(2).translate(_DIGIT_CONFUSABLES))
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
if not (1 <= page <= total <= 500):
|
||||||
|
return None
|
||||||
|
return page, total
|
||||||
|
|
||||||
|
|
||||||
|
def extract_doc_title(text: str) -> str | None:
|
||||||
|
"""Recognized printed document title (as a filename-ready slug)."""
|
||||||
|
for pattern, slug in _DOC_TITLE_PATTERNS:
|
||||||
|
if pattern.search(text):
|
||||||
|
return slug
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# TD / credit-union letterheads print the account holder as SHORTNAME.
|
||||||
|
# Also match "Account name:" / "Customer:" / name-on-file style labels.
|
||||||
|
_PARTY_RE = re.compile(
|
||||||
|
r"\b(?:SHORT\s*NAME|ACCOUNT\s*NAME|CUSTOMER(?:\s*NAME)?|NAME)\s*:?\s*"
|
||||||
|
r"([A-Z][A-Z0-9 .'-]{1,40})",
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def extract_party(text: str) -> str | None:
|
||||||
|
"""Account holder / shortname printed on the document, if labeled."""
|
||||||
|
match = _PARTY_RE.search(text)
|
||||||
|
if not match:
|
||||||
|
return None
|
||||||
|
name = re.sub(r"\s+", " ", match.group(1)).strip(" .,-")
|
||||||
|
# Reject labels that ate the next field ("LEVIT I PERIOD").
|
||||||
|
name = re.split(r"\b(?:PERIOD|BR|ACCOUNT|FROM|TO|PAGE)\b", name, maxsplit=1)[0].strip()
|
||||||
|
if len(name) < 2:
|
||||||
|
return None
|
||||||
|
return name
|
||||||
|
|
||||||
|
|
||||||
|
def clean_vendor(vendor: str | None) -> str | None:
|
||||||
|
"""Strip OCR junk from a vendor line; reject it entirely if it's gibberish.
|
||||||
|
|
||||||
|
Tesseract routinely bolts noise onto real letterhead text: "| ») Transaction
|
||||||
|
History i", "7D TD Canada Trust", "+7 TD Canada Trus". Leading/trailing
|
||||||
|
tokens that aren't real words get trimmed; if fewer than half the remaining
|
||||||
|
tokens look wordlike ("BH tc rn te So Gee EA Nie..."), the whole line is
|
||||||
|
treated as noise and dropped so naming can fall back to something better.
|
||||||
|
"""
|
||||||
|
if not vendor:
|
||||||
|
return None
|
||||||
|
tokens = vendor.split()
|
||||||
|
|
||||||
|
def is_word(tok: str) -> bool:
|
||||||
|
# Judge on letters only, so hyphens/OCR dashes inside real names
|
||||||
|
# ("PETRO-—CANADA") don't disqualify the token.
|
||||||
|
core = _LETTERS_RE.sub("", tok)
|
||||||
|
return len(core) >= 3 and any(c in _VOWELS for c in core.lower())
|
||||||
|
|
||||||
|
def is_acronym(tok: str) -> bool:
|
||||||
|
# Pure 2-5 uppercase letters ("TD", "CRA") — "7D" or "»)" don't count.
|
||||||
|
return tok.isalpha() and tok.isupper() and 2 <= len(tok) <= 5
|
||||||
|
|
||||||
|
def keepable(tok: str) -> bool:
|
||||||
|
return is_word(tok) or is_acronym(tok)
|
||||||
|
|
||||||
|
while tokens and not keepable(tokens[0]):
|
||||||
|
tokens.pop(0)
|
||||||
|
while tokens and not keepable(tokens[-1]):
|
||||||
|
tokens.pop()
|
||||||
|
if not tokens:
|
||||||
|
return None
|
||||||
|
words = sum(1 for t in tokens if is_word(t))
|
||||||
|
if words < max(1, len(tokens) / 2):
|
||||||
|
return None
|
||||||
|
return " ".join(tokens)
|
||||||
|
|
||||||
|
|
||||||
|
# Real CRA slips always carry recognizable boilerplate near the code. Short
|
||||||
|
# codes like "T4" otherwise false-positive on garbled OCR of unrelated
|
||||||
|
# documents (a misread "LEVIT I" once produced a confident "T4").
|
||||||
|
_FORM_CONTEXT_RE = re.compile(
|
||||||
|
r"STATEMENT OF|ETAT D|SOMMAIRE|SUMMARY OF|CANADA REVENUE|AGENCE DU REVENU"
|
||||||
|
r"|REMUNERATION|PENSION|DISPOSITIONS|TUITION|PROTECTED B|PROT[EÉ]G[EÉ] B",
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def extract_form_code(text: str) -> str | None:
|
def extract_form_code(text: str) -> str | None:
|
||||||
"""Recognized Canadian tax slip code (T4, T4A, T5008, ...), if present."""
|
"""Recognized Canadian tax slip code (T4, T4A, T5008, ...), if present."""
|
||||||
match = _FORM_CODE_RE.search(text.upper())
|
match = _FORM_CODE_RE.search(text.upper())
|
||||||
return match.group(1) if match else None
|
if not match:
|
||||||
|
return None
|
||||||
|
if not _FORM_CONTEXT_RE.search(text):
|
||||||
|
return None
|
||||||
|
return match.group(1)
|
||||||
|
|
||||||
|
|
||||||
def extract_tax_year(text: str) -> str | None:
|
def extract_tax_year(text: str) -> str | None:
|
||||||
@@ -160,6 +324,11 @@ class OcrResult:
|
|||||||
form_code: str | None = None # e.g. "T4", "T5008", if a known tax slip
|
form_code: str | None = None # e.g. "T4", "T5008", if a known tax slip
|
||||||
tax_year: str | None = None # e.g. "2023" from "Year 2023" on a T4
|
tax_year: str | None = None # e.g. "2023" from "Year 2023" on a T4
|
||||||
org_name: str | None = None # employer/payer on tax slips
|
org_name: str | None = None # employer/payer on tax slips
|
||||||
|
doc_title: str | None = None # recognized printed title slug, e.g. "transaction_history"
|
||||||
|
party: str | None = None # account holder / SHORTNAME when labeled
|
||||||
|
page_number: int | None = None # from a printed "PAGE X OF Y"
|
||||||
|
page_total: int | None = None
|
||||||
|
line_items: list[tuple[str, str]] | None = None # (description, "12.34")
|
||||||
|
|
||||||
|
|
||||||
def _normalize_date(match: re.Match, kind: str) -> str | None:
|
def _normalize_date(match: re.Match, kind: str) -> str | None:
|
||||||
@@ -176,7 +345,10 @@ def _normalize_date(match: re.Match, kind: str) -> str | None:
|
|||||||
return None
|
return None
|
||||||
else:
|
else:
|
||||||
return None
|
return None
|
||||||
if not (1 <= m <= 12 and 1 <= d <= 31 and 1990 <= y <= 2100):
|
# Upper bound is next year, not 2100: a scanned document can't be
|
||||||
|
# dated decades in the future, and OCR misreads (2023 -> 2053)
|
||||||
|
# otherwise produce confidently wrong filenames.
|
||||||
|
if not (1 <= m <= 12 and 1 <= d <= 31 and 1990 <= y <= datetime.now().year + 1):
|
||||||
return None
|
return None
|
||||||
return f"{y:04d}-{m:02d}-{d:02d}"
|
return f"{y:04d}-{m:02d}-{d:02d}"
|
||||||
except (ValueError, TypeError):
|
except (ValueError, TypeError):
|
||||||
@@ -185,6 +357,8 @@ def _normalize_date(match: re.Match, kind: str) -> str | None:
|
|||||||
|
|
||||||
def extract_date(text: str) -> str | None:
|
def extract_date(text: str) -> str | None:
|
||||||
"""First plausible date found in OCR text, normalized to YYYY-MM-DD."""
|
"""First plausible date found in OCR text, normalized to YYYY-MM-DD."""
|
||||||
|
text = _O_BEFORE_DIGIT_RE.sub("0", text)
|
||||||
|
text = _O_AFTER_DIGIT_RE.sub("0", text)
|
||||||
for pattern, kind in _DATE_PATTERNS:
|
for pattern, kind in _DATE_PATTERNS:
|
||||||
match = pattern.search(text)
|
match = pattern.search(text)
|
||||||
if match:
|
if match:
|
||||||
@@ -217,6 +391,29 @@ def extract_total(text: str) -> str | None:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def extract_line_items(text: str) -> list[tuple[str, str]]:
|
||||||
|
"""SKU lines as (description, amount_str), skipping totals/tender noise.
|
||||||
|
|
||||||
|
Amounts stay strings (same convention as extract_total). Categorization /
|
||||||
|
split booking lives in taxes `scripts/receipt_itemize.py`.
|
||||||
|
"""
|
||||||
|
out: list[tuple[str, str]] = []
|
||||||
|
for raw in text.splitlines():
|
||||||
|
line = re.sub(r"\s+", " ", raw).strip(" -\t")
|
||||||
|
if not line or len(line) < 3 or _ITEM_SKIP_RE.search(line):
|
||||||
|
continue
|
||||||
|
match = _ITEM_LINE_RE.match(line)
|
||||||
|
if not match:
|
||||||
|
continue
|
||||||
|
desc = match.group(1).strip(" -$")
|
||||||
|
if not desc or _ITEM_SKIP_RE.search(desc):
|
||||||
|
continue
|
||||||
|
if len(re.sub(r"[^A-Za-z]", "", desc)) < 2:
|
||||||
|
continue
|
||||||
|
out.append((re.sub(r"\s+", " ", desc), match.group(2)))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
def extract_vendor(
|
def extract_vendor(
|
||||||
image_bgr: np.ndarray,
|
image_bgr: np.ndarray,
|
||||||
lang: str = "eng",
|
lang: str = "eng",
|
||||||
@@ -303,13 +500,15 @@ def run_ocr(
|
|||||||
confidences = [float(c) for c in data["conf"] if c not in ("-1", -1)]
|
confidences = [float(c) for c in data["conf"] if c not in ("-1", -1)]
|
||||||
mean_confidence = sum(confidences) / len(confidences) if confidences else 0.0
|
mean_confidence = sum(confidences) / len(confidences) if confidences else 0.0
|
||||||
|
|
||||||
vendor = extract_vendor(
|
vendor = clean_vendor(
|
||||||
|
extract_vendor(
|
||||||
image_bgr,
|
image_bgr,
|
||||||
lang=lang,
|
lang=lang,
|
||||||
top_fraction=vendor_top_fraction,
|
top_fraction=vendor_top_fraction,
|
||||||
min_word_confidence=min_word_confidence,
|
min_word_confidence=min_word_confidence,
|
||||||
psm=psm,
|
psm=psm,
|
||||||
)
|
)
|
||||||
|
)
|
||||||
date = extract_date(text)
|
date = extract_date(text)
|
||||||
time = extract_time(text)
|
time = extract_time(text)
|
||||||
total = extract_total(text)
|
total = extract_total(text)
|
||||||
@@ -324,6 +523,9 @@ def run_ocr(
|
|||||||
if form_code:
|
if form_code:
|
||||||
vendor = f"{form_code} {org_name}" if org_name else form_code
|
vendor = f"{form_code} {org_name}" if org_name else form_code
|
||||||
|
|
||||||
|
page_marker = extract_page_marker(text)
|
||||||
|
line_items = extract_line_items(text) or None
|
||||||
|
|
||||||
return OcrResult(
|
return OcrResult(
|
||||||
text=text,
|
text=text,
|
||||||
vendor=vendor,
|
vendor=vendor,
|
||||||
@@ -334,6 +536,11 @@ def run_ocr(
|
|||||||
form_code=form_code,
|
form_code=form_code,
|
||||||
tax_year=tax_year,
|
tax_year=tax_year,
|
||||||
org_name=org_name,
|
org_name=org_name,
|
||||||
|
doc_title=extract_doc_title(text),
|
||||||
|
party=extract_party(text),
|
||||||
|
page_number=page_marker[0] if page_marker else None,
|
||||||
|
page_total=page_marker[1] if page_marker else None,
|
||||||
|
line_items=line_items,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -42,7 +42,9 @@ def _downscale(image_bgr: np.ndarray, max_dim: int = 1000) -> np.ndarray:
|
|||||||
if longest <= max_dim:
|
if longest <= max_dim:
|
||||||
return image_bgr
|
return image_bgr
|
||||||
scale = max_dim / longest
|
scale = max_dim / longest
|
||||||
return cv2.resize(image_bgr, (round(w * scale), round(h * scale)), interpolation=cv2.INTER_AREA)
|
new_w = max(1, round(w * scale))
|
||||||
|
new_h = max(1, round(h * scale))
|
||||||
|
return cv2.resize(image_bgr, (new_w, new_h), interpolation=cv2.INTER_AREA)
|
||||||
|
|
||||||
|
|
||||||
def _osd_rotation(image_bgr: np.ndarray, lang: str) -> int | None:
|
def _osd_rotation(image_bgr: np.ndarray, lang: str) -> int | None:
|
||||||
|
|||||||
+117
-1
@@ -5,7 +5,20 @@ deterministic and don't depend on Tesseract's OCR accuracy. End-to-end OCR
|
|||||||
accuracy against rendered receipts is exercised in test_ocr_integration.py.
|
accuracy against rendered receipts is exercised in test_ocr_integration.py.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from paperpod.ocr.extract import extract_date, extract_form_code, extract_org_name, extract_store_name, extract_tax_year, extract_time, extract_total
|
from paperpod.ocr.extract import (
|
||||||
|
clean_vendor,
|
||||||
|
extract_date,
|
||||||
|
extract_doc_title,
|
||||||
|
extract_form_code,
|
||||||
|
extract_line_items,
|
||||||
|
extract_org_name,
|
||||||
|
extract_page_marker,
|
||||||
|
extract_party,
|
||||||
|
extract_store_name,
|
||||||
|
extract_tax_year,
|
||||||
|
extract_time,
|
||||||
|
extract_total,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_extract_date_iso():
|
def test_extract_date_iso():
|
||||||
@@ -19,6 +32,8 @@ def test_extract_date_slash_mdy():
|
|||||||
def test_extract_date_month_name():
|
def test_extract_date_month_name():
|
||||||
assert extract_date("Invoice date: March 14, 2023") == "2023-03-14"
|
assert extract_date("Invoice date: March 14, 2023") == "2023-03-14"
|
||||||
assert extract_date("Mar 14 2023") == "2023-03-14"
|
assert extract_date("Mar 14 2023") == "2023-03-14"
|
||||||
|
# OCR often reads the comma as a period ("July 13. 2026").
|
||||||
|
assert extract_date("Date: July 13. 2025") == "2025-07-13"
|
||||||
|
|
||||||
|
|
||||||
def test_extract_date_none_when_absent():
|
def test_extract_date_none_when_absent():
|
||||||
@@ -55,6 +70,23 @@ def test_extract_total_none_when_absent():
|
|||||||
assert extract_total("no totals here") is None
|
assert extract_total("no totals here") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_extract_line_items_skips_totals_and_keeps_skus():
|
||||||
|
text = (
|
||||||
|
"COSTCO WHOLESALE\n"
|
||||||
|
"1234567 LEGO CLASSIC SET 24.99 H\n"
|
||||||
|
"KIRKLAND PAPER TOWEL 18.99\n"
|
||||||
|
"SUBTOTAL 43.98\n"
|
||||||
|
"HST 5.72\n"
|
||||||
|
"TOTAL 49.70\n"
|
||||||
|
"MASTERCARD 49.70\n"
|
||||||
|
)
|
||||||
|
items = extract_line_items(text)
|
||||||
|
assert items == [
|
||||||
|
("LEGO CLASSIC SET", "24.99"),
|
||||||
|
("KIRKLAND PAPER TOWEL", "18.99"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
def test_extract_time_hms():
|
def test_extract_time_hms():
|
||||||
assert extract_time("03/20/23 15:25:35") == "15:25"
|
assert extract_time("03/20/23 15:25:35") == "15:25"
|
||||||
|
|
||||||
@@ -95,6 +127,12 @@ def test_extract_form_code_none_for_receipt():
|
|||||||
assert extract_form_code("WALMART\nSUBTOTAL 21.97\nTOTAL 23.71") is None
|
assert extract_form_code("WALMART\nSUBTOTAL 21.97\nTOTAL 23.71") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_extract_form_code_requires_slip_boilerplate():
|
||||||
|
# Garbled OCR of a bank statement once misread "LEVIT I" as "T4";
|
||||||
|
# without CRA boilerplate nearby the code must not count.
|
||||||
|
assert extract_form_code("HORT NAMY nV T4\nRAN AMOUNY HATL") is None
|
||||||
|
|
||||||
|
|
||||||
def test_extract_org_name_finds_employer():
|
def test_extract_org_name_finds_employer():
|
||||||
text = (
|
text = (
|
||||||
"Employer's name\nYORK UNIVERSITY Year 2023 Statement of Remuneration Paid\n"
|
"Employer's name\nYORK UNIVERSITY Year 2023 Statement of Remuneration Paid\n"
|
||||||
@@ -138,3 +176,81 @@ def test_extract_store_name_costco():
|
|||||||
|
|
||||||
def test_extract_store_name_walmart():
|
def test_extract_store_name_walmart():
|
||||||
assert extract_store_name("HOW DID WE DO TODAY?\nWalmart") == "Walmart"
|
assert extract_store_name("HOW DID WE DO TODAY?\nWalmart") == "Walmart"
|
||||||
|
|
||||||
|
|
||||||
|
def test_extract_date_rejects_far_future_ocr_misreads():
|
||||||
|
# "2053" is a misread of "2023" — a document can't be dated decades ahead.
|
||||||
|
assert extract_date("PERIOD ENDING 2053-06-30") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_extract_date_statement_period_end_wins():
|
||||||
|
# The period end is the statement's document date, not the first
|
||||||
|
# transaction row.
|
||||||
|
text = "PERIOD: FROM : 01/ 01/ 2025 TO : 01/ 31/ 2025\n01/02/2025 PYT TO"
|
||||||
|
assert extract_date(text) == "2025-01-31"
|
||||||
|
|
||||||
|
|
||||||
|
def test_extract_date_tolerates_ocr_spaces_and_letter_o():
|
||||||
|
assert extract_date("TO : O1/ 31/2025") == "2025-01-31"
|
||||||
|
assert extract_date("12/ 31/2024") == "2024-12-31"
|
||||||
|
|
||||||
|
|
||||||
|
def test_extract_page_marker_english():
|
||||||
|
assert extract_page_marker("DEPOSIT ACCOUNT HISTORY PAGE 1 OF 2") == (1, 2)
|
||||||
|
assert extract_page_marker("PAGE 13 OF 17") == (13, 17)
|
||||||
|
|
||||||
|
|
||||||
|
def test_extract_page_marker_french():
|
||||||
|
assert extract_page_marker("HISTORIQUE DES OPERATIONS PAGE 2 DE 3") == (2, 3)
|
||||||
|
|
||||||
|
|
||||||
|
def test_extract_page_marker_rejects_nonsense():
|
||||||
|
assert extract_page_marker("PAGE 5 OF 2") is None
|
||||||
|
assert extract_page_marker("no marker here") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_extract_doc_title_transaction_history():
|
||||||
|
assert extract_doc_title("TD Canada Trust\nTransaction History") == "transaction_history"
|
||||||
|
assert extract_doc_title("Historique des opérations") == "transaction_history"
|
||||||
|
|
||||||
|
|
||||||
|
def test_extract_doc_title_direct_deposit():
|
||||||
|
assert (
|
||||||
|
extract_doc_title("Government of Canada - Direct Deposit Enrolment")
|
||||||
|
== "direct_deposit"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_extract_doc_title_none_for_receipt():
|
||||||
|
assert extract_doc_title("WALMART\nSUBTOTAL 21.97") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_extract_party_shortname():
|
||||||
|
text = "BR # : 1076 ACCOUNT: 6254276 MBA - MIN SHORTNAME : LEVIT I\nPERIOD: FROM"
|
||||||
|
assert extract_party(text) == "LEVIT I"
|
||||||
|
|
||||||
|
|
||||||
|
def test_extract_party_none_when_absent():
|
||||||
|
assert extract_party("WALMART\nTOTAL 23.71") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_clean_vendor_strips_edge_junk():
|
||||||
|
assert clean_vendor("| ») Transaction History i") == "Transaction History"
|
||||||
|
assert clean_vendor("7D TD Canada Trust") == "TD Canada Trust"
|
||||||
|
assert clean_vendor("+7 TD Canada Trus") == "TD Canada Trus"
|
||||||
|
assert clean_vendor(": TD Canada Trust") == "TD Canada Trust"
|
||||||
|
|
||||||
|
|
||||||
|
def test_clean_vendor_rejects_gibberish():
|
||||||
|
assert clean_vendor("BH tc rn te So Gee EA Nie PR ae UE aa a RR Pia") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_clean_vendor_keeps_real_names():
|
||||||
|
assert clean_vendor("TD Canada Trust") == "TD Canada Trust"
|
||||||
|
assert clean_vendor("Costco") == "Costco"
|
||||||
|
assert clean_vendor("Historique des opérations") == "Historique des opérations"
|
||||||
|
|
||||||
|
|
||||||
|
def test_clean_vendor_none_passthrough():
|
||||||
|
assert clean_vendor(None) is None
|
||||||
|
assert clean_vendor("|| 123") is None
|
||||||
|
|||||||
Reference in New Issue
Block a user