Add OCR naming and PNG-to-PDF export (modules 5-7, single-page only)

- ocr/: Tesseract-based vendor/date/total extraction. PSM 6 (single
  uniform text block) instead of the default fixes receipts where
  item-name/price columns were otherwise split into separate blocks and
  dropped. Vendor line picked by median (not max) word height within the
  top fraction of the crop, breaking ties by topmost line - max() was
  fooled by single descender/ascender glyphs (commas, parens) inflating
  one word's bounding box.
- naming/: merge OCR metadata into YYYY-MM-DD_vendor.pdf filenames,
  degrading gracefully to UNSORTED_<timestamp>.pdf; per-run dedup.
- pdf/: img2pdf-based multi-page-capable assembly (images_to_pdf).
- pipeline.run_export(): OCR + name + PDF each detected crop from a
  detect() report, writes pdf/ and export_summary.csv.
- New `paperpod export <video>` CLI command.
- Verified against synthetic Home Depot/Metro/Petro-Canada receipts:
  3/3 correct vendor, date, and total after tuning.

Known limitation (documented in README): no page-flip/multi-page
grouping yet (that's events/, still unbuilt) - every detected document
becomes its own single-page PDF.
This commit is contained in:
2026-07-07 18:04:06 -04:00
parent 8f5be59b7e
commit 2c28a623fb
14 changed files with 723 additions and 12 deletions
+24 -3
View File
@@ -24,11 +24,18 @@ offline on your machine.
| `vision/` | Motion detection, document contours, perspective crop, sharpness | Built |
| `events/` | State machine: placed / page-flipped / cleared, pod grouping | Planned |
| `transcribe/` | Local speech-to-text (faster-whisper) | Planned |
| `ocr/` | OCR fallback naming (Tesseract) | Planned |
| `naming/` | Final filename assembly + summary CSV | Planned |
| `pdf/` | PDF assembly + Paperless-ngx consume staging | Planned |
| `ocr/` | OCR fallback naming (Tesseract): vendor, date, total | Built |
| `naming/` | Final filename assembly + summary CSV | Built |
| `pdf/` | PDF assembly (img2pdf) | Built — single-page only, no consume-folder staging |
| `review_cli/` | Pre-export review (rename/merge/split) | Planned |
**Known limitation:** there is no page-flip / multi-page grouping yet
(that's `events/`). Every detected document currently becomes its own
single-page PDF, even if it was physically one page of a stack or one side
of a double-sided document. Don't point `export` at the Paperless-ngx
consume folder for multi-page documents until `events/` exists — you'll get
one PDF per page instead of one PDF per document.
## Setup
Requires Python 3.11+ and ffmpeg (`brew install ffmpeg`).
@@ -51,6 +58,9 @@ python -m paperpod probe sample_data/videos/synthetic_sample.mp4
# Detect stable windows + document candidates
python -m paperpod detect sample_data/videos/synthetic_sample.mp4
# Detect, OCR-name, and export each document as its own PDF
python -m paperpod export sample_data/videos/receipts_sample.mp4
# Extract the audio track (16 kHz mono WAV, whisper-ready)
python -m paperpod extract-audio my_recording.mp4
```
@@ -62,6 +72,17 @@ python -m paperpod extract-audio my_recording.mp4
- `report.json` — video metadata, motion events, stable windows, detections
- `motion_scores.csv` — per-sample motion scores, for tuning `motion.threshold`
`export` runs `detect` and then, for every detected document, additionally writes:
- `pdf/<name>.pdf` — one single-page PDF per detected document, named
`YYYY-MM-DD_vendor.pdf` from OCR-extracted date/vendor, or
`UNSORTED_<timestamp>.pdf` if OCR couldn't find anything usable
- `export_summary.csv` — timestamp, pod_id, page_count, source_of_name
(`ocr`/`none`), OCR vendor/date/total/confidence, final filename
Nothing is copied into a Paperless-ngx consume directory yet — review the
`pdf/` folder yourself before moving files anywhere.
## Tuning
All thresholds live in `config.yaml` (motion sensitivity, stable window
+37 -1
View File
@@ -3,6 +3,7 @@
Usage:
python -m paperpod probe <video>
python -m paperpod detect <video> [--out DIR] [--config config.yaml]
python -m paperpod export <video> [--out DIR] [--config config.yaml]
python -m paperpod extract-audio <video> [--out FILE] [--config config.yaml]
"""
@@ -15,7 +16,7 @@ from pathlib import Path
from paperpod.capture.audio import extract_audio
from paperpod.capture.video import probe_video
from paperpod.config import load_config
from paperpod.pipeline import run_detection
from paperpod.pipeline import run_detection, run_export
def _add_config_arg(parser: argparse.ArgumentParser) -> None:
@@ -73,6 +74,33 @@ def cmd_detect(args: argparse.Namespace) -> int:
return 0
def cmd_export(args: argparse.Namespace) -> int:
cfg = _resolve_config(args.config)
out_dir = Path(args.out or Path(cfg.output.dir) / Path(args.video).stem)
print(f"Analyzing {args.video} -> {out_dir}")
report = run_detection(args.video, cfg, out_dir)
found = [w for w in report["stable_windows"] if w["document_found"]]
print(f"Running OCR + naming on {len(found)} detected document(s)...")
result = run_export(report, cfg, out_dir)
print(f"\n{'pod':>3} {'date':>10} vendor / final filename")
print("-" * 60)
for row in result["rows"]:
vendor = row["ocr_vendor"] or "(no vendor found)"
print(f"{row['pod_id']:>3} {row['ocr_date'] or '-':>10} {vendor}")
print(f" -> {row['pdf_path']} [source: {row['source_of_name']}]")
print(f"\nPDFs: {result['pdf_dir']}")
print(f"Summary: {out_dir / 'export_summary.csv'}")
print(
"\nNote: no page-flip/multi-page grouping yet — each detected "
"document is a separate single-page PDF."
)
return 0
def cmd_extract_audio(args: argparse.Namespace) -> int:
cfg = _resolve_config(args.config)
out = Path(args.out or Path(cfg.output.dir) / (Path(args.video).stem + ".wav"))
@@ -100,6 +128,14 @@ def main(argv: list[str] | None = None) -> int:
_add_config_arg(p_detect)
p_detect.set_defaults(func=cmd_detect)
p_export = sub.add_parser(
"export", help="Detect, OCR-name, and export each document as a PDF"
)
p_export.add_argument("video")
p_export.add_argument("--out", default=None, help="Output directory")
_add_config_arg(p_export)
p_export.set_defaults(func=cmd_export)
p_audio = sub.add_parser("extract-audio", help="Extract audio track as WAV")
p_audio.add_argument("video")
p_audio.add_argument("--out", default=None, help="Output WAV path")
+30
View File
@@ -42,6 +42,34 @@ class SpeechConfig:
window_after_s: float = 5.0
@dataclass
class OcrConfig:
# Tesseract language pack (e.g. "eng", "eng+fra").
lang: str = "eng"
# Page segmentation mode. 6 = "assume a single uniform block of text",
# which (unlike Tesseract's default of 3) keeps each receipt line's
# item-name and price together instead of splitting them into separate
# column blocks and dropping one. Verified against synthetic Home Depot/
# Metro/Petro-Canada receipts; revisit if real receipts have a layout
# PSM 6 handles worse (e.g. multi-column letters).
psm: int = 6
# Vendor name is assumed to live in this top fraction of the crop
# (receipt headers / letterheads are typically near the top).
vendor_top_fraction: float = 0.4
# Words below this Tesseract confidence (0-100) are ignored when
# picking the vendor line. Kept low deliberately: header text (larger,
# sometimes bold/stylized fonts) can score much lower confidence than
# crisp monospace body text despite being read correctly, and the
# line-height + topmost heuristic already screens out low-confidence
# noise (garbled OCR hallucinations measure much shorter than real text).
min_word_confidence: float = 10.0
@dataclass
class NamingConfig:
max_vendor_len: int = 40
@dataclass
class OutputConfig:
dir: str = "./output"
@@ -55,6 +83,8 @@ class Config:
motion: MotionConfig = field(default_factory=MotionConfig)
document: DocumentConfig = field(default_factory=DocumentConfig)
speech: SpeechConfig = field(default_factory=SpeechConfig)
ocr: OcrConfig = field(default_factory=OcrConfig)
naming: NamingConfig = field(default_factory=NamingConfig)
output: OutputConfig = field(default_factory=OutputConfig)
+7 -3
View File
@@ -1,5 +1,9 @@
"""Module 6 (not built yet): merge speech/OCR metadata into final filenames.
"""Module 6: merge OCR (and later speech) metadata into final filenames.
Pattern: YYYY-MM-DD_source_description.pdf, falling back to
UNSORTED_<timestamp>.pdf, plus a summary CSV per run.
Pattern: YYYY-MM-DD_vendor.pdf, falling back to UNSORTED_<timestamp>.pdf.
The per-run summary CSV is written by paperpod.pdf.export.
"""
from paperpod.naming.filename import NamingResult, build_name, dedupe_stem, slugify
__all__ = ["NamingResult", "build_name", "dedupe_stem", "slugify"]
+59
View File
@@ -0,0 +1,59 @@
"""Build the final filename/PDF title from OCR (and, later, speech) metadata.
Pattern: YYYY-MM-DD_vendor.pdf, falling back progressively as pieces of
information go missing, down to UNSORTED_<timestamp>.pdf when nothing usable
was extracted at all. Module 4 (speech) isn't built yet, so "description" in
the original spec's pattern is currently always the OCR vendor slug; once
speech transcription lands, a spoken description will take priority here.
"""
from __future__ import annotations
import re
from dataclasses import dataclass
from datetime import datetime
def slugify(text: str, max_len: int = 40) -> str:
text = text.lower()
text = re.sub(r"[^a-z0-9]+", "_", text).strip("_")
text = text[:max_len].strip("_")
return text or "doc"
@dataclass
class NamingResult:
stem: str # filename without extension
source: str # "ocr" | "none" (will include "speech" once module 4 exists)
def build_name(
date: str | None,
vendor: str | None,
fallback_dt: datetime,
max_vendor_len: int = 40,
) -> NamingResult:
"""date must already be normalized to YYYY-MM-DD, or None."""
vendor_slug = slugify(vendor, max_vendor_len) if vendor else None
ts = fallback_dt.strftime("%Y%m%d_%H%M%S")
if date and vendor_slug:
return NamingResult(stem=f"{date}_{vendor_slug}", source="ocr")
if date:
return NamingResult(stem=f"{date}_UNSORTED", source="ocr")
if vendor_slug:
return NamingResult(stem=f"UNSORTED_{vendor_slug}_{ts}", source="ocr")
return NamingResult(stem=f"UNSORTED_{ts}", source="none")
def dedupe_stem(stem: str, used: set[str]) -> str:
"""Append _2, _3, ... if stem was already used in this run."""
if stem not in used:
used.add(stem)
return stem
i = 2
while f"{stem}_{i}" in used:
i += 1
deduped = f"{stem}_{i}"
used.add(deduped)
return deduped
+8 -3
View File
@@ -1,5 +1,10 @@
"""Module 5 (not built yet): OCR fallback via Tesseract (pytesseract).
"""Module 5: OCR fallback via Tesseract (pytesseract).
Will extract vendor name, date, and total from crops that have no matching
speech segment.
Extracts vendor name, date, and total from a document crop. Currently used
for every crop (module 4's speech transcription isn't built yet, so there is
no "speech vs. needs-OCR-fallback" branch to take yet).
"""
from paperpod.ocr.extract import OcrResult, extract_date, extract_total, extract_vendor, run_ocr
__all__ = ["OcrResult", "extract_date", "extract_total", "extract_vendor", "run_ocr"]
+206
View File
@@ -0,0 +1,206 @@
"""OCR fallback naming: pull vendor / date / total out of a document crop.
Used when no spoken description is available (module 4, not built yet).
Tesseract (via pytesseract) does the character recognition; everything else
here is regex/heuristics tuned for North American receipts and letters.
"""
from __future__ import annotations
import re
import statistics
from dataclasses import dataclass
import cv2
import numpy as np
import pytesseract
MONTHS = {
"jan": 1, "january": 1, "feb": 2, "february": 2, "mar": 3, "march": 3,
"apr": 4, "april": 4, "may": 5, "jun": 6, "june": 6, "jul": 7, "july": 7,
"aug": 8, "august": 8, "sep": 9, "sept": 9, "september": 9, "oct": 10,
"october": 10, "nov": 11, "november": 11, "dec": 12, "december": 12,
}
# Ordered most- to least-specific; first match wins.
_DATE_PATTERNS: list[tuple[re.Pattern, str]] = [
(re.compile(r"\b(\d{4})[-/](\d{1,2})[-/](\d{1,2})\b"), "ymd"),
(
re.compile(
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",
re.IGNORECASE,
),
"month_name_dmy",
),
# Assumes North American MM/DD/YYYY (matches Home Depot / Metro / Petro-Canada
# style receipts this app was designed around). Revisit if you record
# receipts using DD/MM/YYYY formatting.
(re.compile(r"\b(\d{1,2})[/-](\d{1,2})[/-](\d{4})\b"), "mdy"),
]
_TOTAL_LINE_RE = re.compile(r"\btotal\b", re.IGNORECASE)
_MONEY_RE = re.compile(r"\$?\s*(\d{1,4}\.\d{2})\b")
@dataclass
class OcrResult:
text: str
vendor: str | None
date: str | None # normalized YYYY-MM-DD
total: str | None # e.g. "81.52"
mean_confidence: float # 0-100, avg Tesseract word confidence
def _normalize_date(match: re.Match, kind: str) -> str | None:
try:
if kind == "ymd":
y, m, d = (int(g) for g in match.groups())
elif kind == "mdy":
m, d, y = (int(g) for g in match.groups())
elif kind == "month_name_dmy":
month_str, day_str, year_str = match.groups()
m = MONTHS.get(month_str.lower())
d, y = int(day_str), int(year_str)
if m is None:
return None
else:
return None
if not (1 <= m <= 12 and 1 <= d <= 31 and 1990 <= y <= 2100):
return None
return f"{y:04d}-{m:02d}-{d:02d}"
except (ValueError, TypeError):
return None
def extract_date(text: str) -> str | None:
"""First plausible date found in OCR text, normalized to YYYY-MM-DD."""
for pattern, kind in _DATE_PATTERNS:
match = pattern.search(text)
if match:
normalized = _normalize_date(match, kind)
if normalized:
return normalized
return None
def extract_total(text: str) -> str | None:
"""Dollar amount on a line containing "total" but not "subtotal"."""
lines = text.splitlines()
for i, line in enumerate(lines):
if not _TOTAL_LINE_RE.search(line):
continue
money = _MONEY_RE.search(line)
if not money and i + 1 < len(lines):
money = _MONEY_RE.search(lines[i + 1])
if money:
return money.group(1)
return None
def extract_vendor(
image_bgr: np.ndarray,
lang: str = "eng",
top_fraction: float = 0.4,
min_word_confidence: float = 40.0,
psm: int = 6,
) -> str | None:
"""Largest-font text line within the top portion of the crop.
Receipt headers and letterheads are typically both topmost and printed
larger than body text, so we group Tesseract's word boxes into lines and
pick the tallest line above the confidence floor.
"""
height = image_bgr.shape[0]
cutoff = int(height * top_fraction)
region = image_bgr[:cutoff] if cutoff > 0 else image_bgr
config = f"--psm {psm}"
data = pytesseract.image_to_data(
region, lang=lang, config=config, output_type=pytesseract.Output.DICT
)
lines: dict[tuple[int, int, int], list[int]] = {}
for i, text in enumerate(data["text"]):
if not text.strip():
continue
try:
conf = float(data["conf"][i])
except (TypeError, ValueError):
continue
if conf < min_word_confidence:
continue
key = (data["block_num"][i], data["par_num"][i], data["line_num"][i])
lines.setdefault(key, []).append(i)
if not lines:
return _first_nonblank_line(
pytesseract.image_to_string(region, lang=lang, config=config)
)
def line_stats(indices: list[int]) -> tuple[float, float]:
# Median, not max: a single descender/ascender glyph (comma, "y",
# parenthesis) can inflate one word's bounding box well above the
# line's actual font size, which would otherwise skew line selection.
height = statistics.median(data["height"][i] for i in indices)
top = min(data["top"][i] for i in indices)
return height, top
stats = {key: line_stats(idxs) for key, idxs in lines.items()}
max_height = max(h for h, _ in stats.values())
# Treat near-max heights as the same font size and break ties by
# topmost line, rather than picking whichever line measured tallest.
tolerance = max(3.0, max_height * 0.15)
candidates = [key for key, (h, _) in stats.items() if h >= max_height - tolerance]
best_key = min(candidates, key=lambda k: stats[k][1])
words = [data["text"][i].strip() for i in sorted(lines[best_key])]
vendor = " ".join(w for w in words if w)
return vendor or None
def _first_nonblank_line(text: str) -> str | None:
for line in text.splitlines():
stripped = line.strip()
if stripped:
return stripped
return None
def run_ocr(
image_bgr: np.ndarray,
lang: str = "eng",
vendor_top_fraction: float = 0.4,
min_word_confidence: float = 40.0,
psm: int = 6,
) -> OcrResult:
"""Run Tesseract once for full text, once (on a crop) for vendor detection."""
config = f"--psm {psm}"
text = pytesseract.image_to_string(image_bgr, lang=lang, config=config)
data = pytesseract.image_to_data(
image_bgr, lang=lang, config=config, output_type=pytesseract.Output.DICT
)
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
vendor = extract_vendor(
image_bgr,
lang=lang,
top_fraction=vendor_top_fraction,
min_word_confidence=min_word_confidence,
psm=psm,
)
date = extract_date(text)
total = extract_total(text)
return OcrResult(
text=text, vendor=vendor, date=date, total=total, mean_confidence=mean_confidence
)
def run_ocr_on_path(path: str, **kwargs) -> OcrResult:
image = cv2.imread(path)
if image is None:
raise ValueError(f"Could not read image: {path}")
return run_ocr(image, **kwargs)
+13 -2
View File
@@ -1,2 +1,13 @@
"""Module 7 (not built yet): assemble pod page images into PDFs (img2pdf)
and stage them for the Paperless-ngx consume directory."""
"""Module 7 (partial): assemble page images into PDFs (img2pdf).
images_to_pdf() supports multi-page PDFs given an ordered list of image
paths, but nothing yet decides *which* crops belong together as one
document — that's module 3 (events/, page-flip vs. new-document detection),
which isn't built. Until then, paperpod.pipeline.export_pdfs treats every
detected crop as its own single-page PDF. Staging into a Paperless-ngx
consume directory is also not wired up yet.
"""
from paperpod.pdf.build import images_to_pdf
__all__ = ["images_to_pdf"]
+31
View File
@@ -0,0 +1,31 @@
"""Assemble one or more page images into a single PDF (img2pdf)."""
from __future__ import annotations
from pathlib import Path
import img2pdf
def images_to_pdf(
image_paths: list[str | Path],
out_path: str | Path,
title: str | None = None,
) -> Path:
"""Combine images (in order) into a single multi-page PDF.
img2pdf embeds the source images losslessly (no re-encoding/quality
loss) rather than rasterizing through a rendering step.
"""
if not image_paths:
raise ValueError("images_to_pdf requires at least one image")
out_path = Path(out_path)
out_path.parent.mkdir(parents=True, exist_ok=True)
kwargs = {}
if title:
kwargs["title"] = title.encode("utf-8")
pdf_bytes = img2pdf.convert([str(p) for p in image_paths], **kwargs)
out_path.write_bytes(pdf_bytes)
return out_path
+84
View File
@@ -10,6 +10,7 @@ from __future__ import annotations
import csv
import json
from datetime import datetime, timedelta
from pathlib import Path
from typing import Any
@@ -18,6 +19,9 @@ import numpy as np
from paperpod.capture.video import iter_frames, probe_video
from paperpod.config import Config, config_to_dict
from paperpod.naming.filename import build_name, dedupe_stem
from paperpod.ocr.extract import run_ocr
from paperpod.pdf.build import images_to_pdf
from paperpod.vision.document import detect_document, warp_document
from paperpod.vision.enhance import enhance_for_ocr
from paperpod.vision.motion import Segment, find_segments, motion_score, prepare_gray
@@ -160,3 +164,83 @@ def run_detection(video_path: str | Path, cfg: Config, out_dir: str | Path) -> d
with open(out_dir / "report.json", "w") as f:
json.dump(report, f, indent=2)
return report
def run_export(report: dict[str, Any], cfg: Config, out_dir: str | Path) -> dict[str, Any]:
"""OCR-name and PDF-export every detected crop from a detect() report.
NOTE: there is no page-grouping yet (module 3 / events/ is not built),
so every detected crop becomes its own single-page PDF, even if it was
physically one side of a multi-page stack. pod_id == window_id for now.
Writes:
pdf/<name>.pdf one PDF per detected document
export_summary.csv timestamp, pod_id, page_count, source_of_name,
ocr_vendor, ocr_date, ocr_total, final_filename
Returns the summary rows (also embedded back for programmatic use).
"""
out_dir = Path(out_dir)
pdf_dir = out_dir / "pdf"
pdf_dir.mkdir(parents=True, exist_ok=True)
video_path = Path(report["video"])
duration_s = report["video_meta"]["duration_s"]
try:
recording_start = datetime.fromtimestamp(video_path.stat().st_mtime) - timedelta(
seconds=duration_s
)
except FileNotFoundError:
recording_start = datetime.now() - timedelta(seconds=duration_s)
used_stems: set[str] = set()
rows: list[dict[str, Any]] = []
for window in report["stable_windows"]:
if not window.get("document_found"):
continue
crop_path = out_dir / window["crop_path"]
image = cv2.imread(str(crop_path))
if image is None:
continue
ocr = run_ocr(
image,
lang=cfg.ocr.lang,
vendor_top_fraction=cfg.ocr.vendor_top_fraction,
min_word_confidence=cfg.ocr.min_word_confidence,
psm=cfg.ocr.psm,
)
event_time = recording_start + timedelta(seconds=window["t_start"])
naming = build_name(ocr.date, ocr.vendor, event_time, cfg.naming.max_vendor_len)
stem = dedupe_stem(naming.stem, used_stems)
pdf_path = pdf_dir / f"{stem}.pdf"
images_to_pdf([crop_path], pdf_path, title=stem)
rows.append(
{
"timestamp": event_time.isoformat(timespec="seconds"),
"pod_id": window["window_id"],
"page_count": 1,
"source_of_name": naming.source,
"ocr_vendor": ocr.vendor or "",
"ocr_date": ocr.date or "",
"ocr_total": ocr.total or "",
"ocr_confidence": round(ocr.mean_confidence, 1),
"final_filename": pdf_path.name,
"pdf_path": str(pdf_path.relative_to(out_dir)),
}
)
with open(out_dir / "export_summary.csv", "w", newline="") as f:
fieldnames = [
"timestamp", "pod_id", "page_count", "source_of_name",
"ocr_vendor", "ocr_date", "ocr_total", "ocr_confidence",
"final_filename", "pdf_path",
]
writer = csv.DictWriter(f, fieldnames=fieldnames)
writer.writeheader()
writer.writerows(rows)
return {"rows": rows, "pdf_dir": str(pdf_dir)}
+63
View File
@@ -0,0 +1,63 @@
from datetime import datetime
from paperpod.naming.filename import build_name, dedupe_stem, slugify
def test_slugify_basic():
assert slugify("THE HOME DEPOT") == "the_home_depot"
assert slugify("Café Déjà-Vu!!") == "caf_d_j_vu"
def test_slugify_empty_falls_back():
assert slugify("") == "doc"
assert slugify("!!!") == "doc"
def test_slugify_truncates():
long_name = "a" * 100
assert len(slugify(long_name, max_len=40)) <= 40
def test_build_name_full_metadata():
dt = datetime(2026, 1, 1, 12, 0, 0)
result = build_name("2023-03-14", "THE HOME DEPOT", dt)
assert result.stem == "2023-03-14_the_home_depot"
assert result.source == "ocr"
def test_build_name_date_only():
dt = datetime(2026, 1, 1, 12, 0, 0)
result = build_name("2023-03-14", None, dt)
assert result.stem == "2023-03-14_UNSORTED"
assert result.source == "ocr"
def test_build_name_vendor_only():
dt = datetime(2026, 1, 1, 12, 0, 0)
result = build_name(None, "METRO", dt)
assert result.stem == "UNSORTED_metro_20260101_120000"
assert result.source == "ocr"
def test_build_name_nothing_found():
dt = datetime(2026, 1, 1, 12, 0, 0)
result = build_name(None, None, dt)
assert result.stem == "UNSORTED_20260101_120000"
assert result.source == "none"
def test_dedupe_stem_no_collision():
used: set[str] = set()
assert dedupe_stem("2023-03-14_metro", used) == "2023-03-14_metro"
def test_dedupe_stem_collision_increments():
used: set[str] = set()
first = dedupe_stem("2023-03-14_metro", used)
second = dedupe_stem("2023-03-14_metro", used)
third = dedupe_stem("2023-03-14_metro", used)
assert (first, second, third) == (
"2023-03-14_metro",
"2023-03-14_metro_2",
"2023-03-14_metro_3",
)
+51
View File
@@ -0,0 +1,51 @@
"""End-to-end OCR tests against the synthetic receipt renderer.
Skipped automatically if the tesseract binary isn't installed.
"""
from __future__ import annotations
import shutil
import sys
from pathlib import Path
import pytest
pytestmark = pytest.mark.skipif(
shutil.which("tesseract") is None, reason="tesseract binary not installed"
)
sys.path.insert(0, str(Path(__file__).parent.parent / "sample_data"))
def _receipt_image(index: int):
from make_receipt_video import RECEIPTS, make_receipt_image
return make_receipt_image(RECEIPTS[index])
def test_home_depot_receipt_fields():
from paperpod.ocr.extract import run_ocr
result = run_ocr(_receipt_image(0))
assert result.date == "2023-03-14"
assert result.total == "81.52"
assert "HOME DEPOT" in (result.vendor or "")
def test_metro_receipt_fields():
from paperpod.ocr.extract import run_ocr
result = run_ocr(_receipt_image(1))
assert result.date == "2023-06-02"
assert result.total == "30.63"
assert "METRO" in (result.vendor or "")
def test_petro_canada_receipt_fields():
from paperpod.ocr.extract import run_ocr
result = run_ocr(_receipt_image(2))
assert result.date == "2023-08-19"
assert result.total == "65.10"
assert "PETRO" in (result.vendor or "")
+55
View File
@@ -0,0 +1,55 @@
"""Unit tests for the regex/heuristic parsing logic in paperpod.ocr.extract.
These test the parsing functions directly on strings so they stay
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.
"""
from paperpod.ocr.extract import extract_date, extract_total
def test_extract_date_iso():
assert extract_date("2023-03-14 09:41 #4821") == "2023-03-14"
def test_extract_date_slash_mdy():
assert extract_date("Purchased on 03/14/2023 at noon") == "2023-03-14"
def test_extract_date_month_name():
assert extract_date("Invoice date: March 14, 2023") == "2023-03-14"
assert extract_date("Mar 14 2023") == "2023-03-14"
def test_extract_date_none_when_absent():
assert extract_date("no date here, just text") is None
def test_extract_date_rejects_implausible_values():
# Not a real month/day -> not treated as a date.
assert extract_date("13/45/2023") is None
def test_extract_total_basic():
text = "SUBTOTAL 72.14\nHST 13% 9.38\nTOTAL 81.52\n"
assert extract_total(text) == "81.52"
def test_extract_total_ignores_subtotal():
text = "SUBTOTAL 30.63\n"
assert extract_total(text) is None
def test_extract_total_word_boundary_with_prefix():
# "FUEL TOTAL" should still match "total" as its own word.
text = "FUEL TOTAL 65.10\n"
assert extract_total(text) == "65.10"
def test_extract_total_amount_on_next_line():
text = "TOTAL\n81.52\n"
assert extract_total(text) == "81.52"
def test_extract_total_none_when_absent():
assert extract_total("no totals here") is None
+55
View File
@@ -0,0 +1,55 @@
import numpy as np
import pytest
from PIL import Image
from paperpod.pdf.build import images_to_pdf
def _write_png(path, color):
Image.new("RGB", (200, 300), color).save(path)
def test_images_to_pdf_single_page(tmp_path):
img_path = tmp_path / "page.png"
_write_png(img_path, (255, 255, 255))
out = tmp_path / "out.pdf"
result = images_to_pdf([img_path], out, title="Test Doc")
assert result == out
assert out.exists()
data = out.read_bytes()
assert data.startswith(b"%PDF")
def test_images_to_pdf_multi_page(tmp_path):
paths = []
for i, color in enumerate([(255, 0, 0), (0, 255, 0), (0, 0, 255)]):
p = tmp_path / f"page_{i}.png"
_write_png(p, color)
paths.append(p)
out = tmp_path / "multi.pdf"
images_to_pdf(paths, out)
data = out.read_bytes()
# img2pdf writes one xobject image per page; a 3-page PDF should be
# meaningfully larger than a 1-page PDF of the same-size images.
single = tmp_path / "single.pdf"
images_to_pdf([paths[0]], single)
assert out.stat().st_size > single.stat().st_size
def test_images_to_pdf_requires_at_least_one_image(tmp_path):
with pytest.raises(ValueError):
images_to_pdf([], tmp_path / "empty.pdf")
def test_images_to_pdf_creates_parent_dirs(tmp_path):
img_path = tmp_path / "page.png"
_write_png(img_path, (10, 20, 30))
out = tmp_path / "nested" / "dir" / "out.pdf"
images_to_pdf([img_path], out)
assert out.exists()