Spaces:
Running
Running
Commit ·
0ebef7d
0
Parent(s):
Initial commit — Export Bank Statement extractor (FastAPI)
Browse filesProven pdfplumber + Tesseract engine, /convert API, shared-key auth,
accuracy harness, Railway/Nixpacks config.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- .env.example +15 -0
- .gitignore +12 -0
- Procfile +1 -0
- app/__init__.py +1 -0
- app/api/__init__.py +0 -0
- app/api/convert.py +118 -0
- app/core/__init__.py +0 -0
- app/core/config.py +26 -0
- app/core/files.py +32 -0
- app/engine/__init__.py +0 -0
- app/engine/converter.py +505 -0
- app/main.py +29 -0
- nixpacks.toml +5 -0
- requirements-dev.txt +4 -0
- requirements.txt +6 -0
- tests/fixtures/complex_statement.pdf +131 -0
- tests/fixtures/hard/t1_no_gridlines.pdf +74 -0
- tests/fixtures/hard/t2_scanned.pdf +0 -0
- tests/fixtures/hard/t3_renamed_headers.pdf +74 -0
- tests/fixtures/hard/t4_signed_amount.pdf +74 -0
- tests/fixtures/hard/t5_european_format.pdf +74 -0
- tests/fixtures/hard/t6_multi_account.pdf +74 -0
- tests/fixtures/hard/t7_diff_balance_labels.pdf +74 -0
- tests/fixtures/hard/t8_text_dates.pdf +74 -0
- tests/fixtures/multipage_bf.pdf +106 -0
- tests/fixtures/real_style_statement.pdf +93 -0
- tests/fixtures/sample_statement.pdf +74 -0
- tests/fixtures/two_block.pdf +112 -0
- tests/generators/make_complex_pdf.py +139 -0
- tests/generators/make_hard_tests.py +200 -0
- tests/generators/make_multipage_bf.py +81 -0
- tests/generators/make_real_style_pdf.py +89 -0
- tests/generators/make_sample_pdf.py +122 -0
- tests/test_accuracy.py +85 -0
.env.example
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# --- Extractor service (production) ---
|
| 2 |
+
|
| 3 |
+
# Shared secret the web app must send as X-API-Key. Generate a long random value
|
| 4 |
+
# and set the SAME value as EXTRACTOR_API_KEY on the web app. Stops anyone hitting
|
| 5 |
+
# the extractor directly and bypassing the web quota.
|
| 6 |
+
EBS_API_KEY=change-me-to-a-long-random-string
|
| 7 |
+
|
| 8 |
+
# Lock CORS to your web origin (the browser never calls this directly, but keep it tight).
|
| 9 |
+
EBS_ALLOWED_ORIGINS=https://exportbankstatement.com
|
| 10 |
+
|
| 11 |
+
# Scratch dir for uploads (auto-deleted). Default is ./_work; /tmp is fine on Railway.
|
| 12 |
+
# EBS_WORK_DIR=/tmp/ebs-work
|
| 13 |
+
|
| 14 |
+
# Max upload size in bytes (default 15 MB).
|
| 15 |
+
# EBS_MAX_UPLOAD_BYTES=15728640
|
.gitignore
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
.venv/
|
| 2 |
+
__pycache__/
|
| 3 |
+
*.pyc
|
| 4 |
+
_work/
|
| 5 |
+
*.out.csv
|
| 6 |
+
*.out.xlsx
|
| 7 |
+
.DS_Store
|
| 8 |
+
|
| 9 |
+
# env
|
| 10 |
+
.env
|
| 11 |
+
.env.local
|
| 12 |
+
# keep .env.example tracked
|
Procfile
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
web: uvicorn app.main:app --host 0.0.0.0 --port $PORT
|
app/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
"""Export Bank Statement — extractor service package."""
|
app/api/__init__.py
ADDED
|
File without changes
|
app/api/convert.py
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""POST /convert — PDF in, reconciled statement data out (JSON or .xlsx).
|
| 2 |
+
|
| 3 |
+
Stateless: the uploaded PDF is read into memory, processed in an isolated temp
|
| 4 |
+
dir, and deleted before the response is built. Nothing is persisted.
|
| 5 |
+
"""
|
| 6 |
+
from __future__ import annotations
|
| 7 |
+
|
| 8 |
+
import io
|
| 9 |
+
import logging
|
| 10 |
+
|
| 11 |
+
from fastapi import APIRouter, File, Header, HTTPException, Query, UploadFile
|
| 12 |
+
from fastapi.responses import JSONResponse, StreamingResponse
|
| 13 |
+
from openpyxl import Workbook
|
| 14 |
+
|
| 15 |
+
from app.core.config import API_KEY, MAX_UPLOAD_BYTES
|
| 16 |
+
from app.core.files import temp_pdf
|
| 17 |
+
from app.engine.converter import BankStatementConverter, ExtractedStatement
|
| 18 |
+
|
| 19 |
+
router = APIRouter()
|
| 20 |
+
logger = logging.getLogger("ebs.convert")
|
| 21 |
+
|
| 22 |
+
XLSX_MEDIA = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def _is_pdf(file: UploadFile) -> bool:
|
| 26 |
+
if (file.content_type or "").lower() in {"application/pdf", "application/x-pdf"}:
|
| 27 |
+
return True
|
| 28 |
+
return (file.filename or "").lower().endswith(".pdf")
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def _statement_dict(st: ExtractedStatement) -> dict:
|
| 32 |
+
return {
|
| 33 |
+
"account": st.account,
|
| 34 |
+
"accountHolder": st.account_holder,
|
| 35 |
+
"period": st.period,
|
| 36 |
+
"openingBalance": st.opening_balance,
|
| 37 |
+
"closingBalance": st.closing_balance,
|
| 38 |
+
"reconciles": st.reconciles,
|
| 39 |
+
"reconcileStatus": st.reconcile_status,
|
| 40 |
+
"runningBalanceOk": st.running_balance_ok,
|
| 41 |
+
"statedSummaryOk": st.stated_summary_ok,
|
| 42 |
+
"reconciliationDetail": st.reconciliation_detail,
|
| 43 |
+
"transactions": [
|
| 44 |
+
{
|
| 45 |
+
"date": t.date,
|
| 46 |
+
"description": t.description,
|
| 47 |
+
"debit": t.debit,
|
| 48 |
+
"credit": t.credit,
|
| 49 |
+
"balance": t.balance,
|
| 50 |
+
}
|
| 51 |
+
for t in st.transactions
|
| 52 |
+
],
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
def _xlsx_bytes(statements: list[ExtractedStatement]) -> io.BytesIO:
|
| 57 |
+
wb = Workbook()
|
| 58 |
+
wb.remove(wb.active)
|
| 59 |
+
for n, st in enumerate(statements, 1):
|
| 60 |
+
ws = wb.create_sheet(f"Account{n}")
|
| 61 |
+
ws.append(["Date", "Description", "Debit", "Credit", "Balance"])
|
| 62 |
+
for t in st.transactions:
|
| 63 |
+
ws.append([t.date, t.description, t.debit, t.credit, t.balance])
|
| 64 |
+
buf = io.BytesIO()
|
| 65 |
+
wb.save(buf)
|
| 66 |
+
buf.seek(0)
|
| 67 |
+
return buf
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
@router.post("/convert")
|
| 71 |
+
async def convert(
|
| 72 |
+
file: UploadFile = File(...),
|
| 73 |
+
format: str = Query("json", pattern="^(json|xlsx)$"),
|
| 74 |
+
x_api_key: str | None = Header(default=None),
|
| 75 |
+
):
|
| 76 |
+
if API_KEY and x_api_key != API_KEY:
|
| 77 |
+
raise HTTPException(status_code=401, detail="Unauthorized.")
|
| 78 |
+
if not _is_pdf(file):
|
| 79 |
+
raise HTTPException(status_code=415, detail="Only PDF files are accepted.")
|
| 80 |
+
|
| 81 |
+
data = await file.read()
|
| 82 |
+
if not data:
|
| 83 |
+
raise HTTPException(status_code=400, detail="Empty file.")
|
| 84 |
+
if len(data) > MAX_UPLOAD_BYTES:
|
| 85 |
+
mb = MAX_UPLOAD_BYTES // (1024 * 1024)
|
| 86 |
+
raise HTTPException(status_code=413, detail=f"File too large (max {mb} MB).")
|
| 87 |
+
|
| 88 |
+
try:
|
| 89 |
+
with temp_pdf(data) as pdf_path:
|
| 90 |
+
statements = BankStatementConverter(pdf_path).extract()
|
| 91 |
+
except Exception:
|
| 92 |
+
# Bad user input (corrupt / password-protected / not a real PDF) must
|
| 93 |
+
# surface as 4xx, never a 500. Logged for observability.
|
| 94 |
+
logger.exception("extraction failed for upload %r", file.filename)
|
| 95 |
+
raise HTTPException(
|
| 96 |
+
status_code=422,
|
| 97 |
+
detail="Couldn't read this PDF. It may be corrupt, password-protected, or not a valid PDF.",
|
| 98 |
+
)
|
| 99 |
+
|
| 100 |
+
if not statements or all(not s.transactions for s in statements):
|
| 101 |
+
raise HTTPException(
|
| 102 |
+
status_code=422,
|
| 103 |
+
detail="Couldn't read any transactions. If this is a scanned image, a clearer scan may help.",
|
| 104 |
+
)
|
| 105 |
+
|
| 106 |
+
if format == "xlsx":
|
| 107 |
+
return StreamingResponse(
|
| 108 |
+
_xlsx_bytes(statements),
|
| 109 |
+
media_type=XLSX_MEDIA,
|
| 110 |
+
headers={"Content-Disposition": 'attachment; filename="statement.xlsx"'},
|
| 111 |
+
)
|
| 112 |
+
|
| 113 |
+
return JSONResponse(
|
| 114 |
+
{
|
| 115 |
+
"count": sum(len(s.transactions) for s in statements),
|
| 116 |
+
"statements": [_statement_dict(s) for s in statements],
|
| 117 |
+
}
|
| 118 |
+
)
|
app/core/__init__.py
ADDED
|
File without changes
|
app/core/config.py
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Service configuration & limits (env-overridable)."""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
|
| 4 |
+
import os
|
| 5 |
+
from pathlib import Path
|
| 6 |
+
|
| 7 |
+
# Max upload size in bytes. Bank-statement PDFs are small; cap to stop abuse.
|
| 8 |
+
MAX_UPLOAD_BYTES = int(os.getenv("EBS_MAX_UPLOAD_BYTES", str(15 * 1024 * 1024))) # 15 MB
|
| 9 |
+
|
| 10 |
+
# Local scratch dir for uploads. Must be a real on-disk path: the engine
|
| 11 |
+
# rasterises pages for OCR *next to* the PDF, and Tesseract/Leptonica can't
|
| 12 |
+
# always read the system /tmp. Kept out of git (see .gitignore).
|
| 13 |
+
WORK_DIR = Path(
|
| 14 |
+
os.getenv("EBS_WORK_DIR", str(Path(__file__).resolve().parents[2] / "_work"))
|
| 15 |
+
)
|
| 16 |
+
|
| 17 |
+
# CORS allow-list (comma-separated). "*" for local dev; lock to the web origin
|
| 18 |
+
# before public launch.
|
| 19 |
+
ALLOWED_ORIGINS = [
|
| 20 |
+
o.strip() for o in os.getenv("EBS_ALLOWED_ORIGINS", "*").split(",") if o.strip()
|
| 21 |
+
]
|
| 22 |
+
|
| 23 |
+
# Shared secret the web BFF must send (header X-API-Key). When set, direct
|
| 24 |
+
# callers without it are rejected — so nobody can hit the extractor and bypass
|
| 25 |
+
# the web app's quota. Unset in local dev = open (for convenience).
|
| 26 |
+
API_KEY = os.getenv("EBS_API_KEY") or None
|
app/core/files.py
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Safe temp handling for uploaded bank statements.
|
| 2 |
+
|
| 3 |
+
Bank data must never linger. Each upload is written into its OWN temp directory
|
| 4 |
+
inside WORK_DIR, processed, and the whole directory is removed in a finally block
|
| 5 |
+
— taking the PDF and any OCR scratch images (`.ocr_p*.png`, which the engine
|
| 6 |
+
writes next to the PDF) with it. A per-request directory also keeps concurrent
|
| 7 |
+
requests from clobbering each other's OCR scratch.
|
| 8 |
+
"""
|
| 9 |
+
from __future__ import annotations
|
| 10 |
+
|
| 11 |
+
import contextlib
|
| 12 |
+
import shutil
|
| 13 |
+
import tempfile
|
| 14 |
+
from collections.abc import Iterator
|
| 15 |
+
from pathlib import Path
|
| 16 |
+
|
| 17 |
+
from app.core.config import WORK_DIR
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
@contextlib.contextmanager
|
| 21 |
+
def temp_pdf(data: bytes) -> Iterator[Path]:
|
| 22 |
+
"""Write `data` to an isolated temp dir, yield the PDF path, then delete the
|
| 23 |
+
whole dir (PDF + OCR scratch) no matter what."""
|
| 24 |
+
WORK_DIR.mkdir(parents=True, exist_ok=True)
|
| 25 |
+
workdir = Path(tempfile.mkdtemp(dir=str(WORK_DIR)))
|
| 26 |
+
pdf_path = workdir / "statement.pdf"
|
| 27 |
+
try:
|
| 28 |
+
pdf_path.write_bytes(data)
|
| 29 |
+
yield pdf_path
|
| 30 |
+
finally:
|
| 31 |
+
with contextlib.suppress(OSError):
|
| 32 |
+
shutil.rmtree(workdir, ignore_errors=True)
|
app/engine/__init__.py
ADDED
|
File without changes
|
app/engine/converter.py
ADDED
|
@@ -0,0 +1,505 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
converter.py — hardened "Python-first" bank-statement PDF -> Excel/CSV converter.
|
| 3 |
+
|
| 4 |
+
Free path, no LLM. Handles:
|
| 5 |
+
- gridlined tables (pdfplumber lines)
|
| 6 |
+
- borderless tables (word-position reconstruction)
|
| 7 |
+
- scanned/image PDFs (OCR: PyMuPDF rasterize + Tesseract)
|
| 8 |
+
- renamed headers (synonym map) - single signed Amount column
|
| 9 |
+
- US (1,234.56) and EU (1.234,56) numbers - relabeled opening/closing balances
|
| 10 |
+
- multiple accounts in one PDF (sectioning)
|
| 11 |
+
Reconciliation (opening + Σcredits - Σdebits == closing) gates every result.
|
| 12 |
+
|
| 13 |
+
Usage: python converter.py [statement.pdf] (default ./sample_statement.pdf)
|
| 14 |
+
"""
|
| 15 |
+
from __future__ import annotations
|
| 16 |
+
|
| 17 |
+
import csv
|
| 18 |
+
import math
|
| 19 |
+
import re
|
| 20 |
+
import statistics
|
| 21 |
+
import sys
|
| 22 |
+
from dataclasses import dataclass, field
|
| 23 |
+
from pathlib import Path
|
| 24 |
+
|
| 25 |
+
import pdfplumber
|
| 26 |
+
from openpyxl import Workbook
|
| 27 |
+
|
| 28 |
+
# ---------------- number / date parsing ----------------
|
| 29 |
+
|
| 30 |
+
def parse_money(value) -> float | None:
|
| 31 |
+
if value is None:
|
| 32 |
+
return None
|
| 33 |
+
s = str(value).strip()
|
| 34 |
+
if not s or s in {"-", "—"}:
|
| 35 |
+
return None
|
| 36 |
+
up = s.upper()
|
| 37 |
+
neg = ("(" in s and ")" in s) or "DR" in up or "-" in s # '-' anywhere, e.g. "$-2,942.71"
|
| 38 |
+
t = re.sub(r"[^0-9.,]", "", s)
|
| 39 |
+
if not t:
|
| 40 |
+
return None
|
| 41 |
+
if "." in t and "," in t: # both separators present
|
| 42 |
+
t = t.replace(",", "") if t.rfind(".") > t.rfind(",") else t.replace(".", "").replace(",", ".")
|
| 43 |
+
elif "," in t: # comma only
|
| 44 |
+
t = t.replace(",", ".") if (t.count(",") == 1 and re.search(r",\d{2}$", t)) else t.replace(",", "")
|
| 45 |
+
try:
|
| 46 |
+
v = float(t)
|
| 47 |
+
except ValueError:
|
| 48 |
+
return None
|
| 49 |
+
return -abs(v) if neg else v
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
_MONTHS = {m: i for i, m in enumerate(
|
| 53 |
+
["jan", "feb", "mar", "apr", "may", "jun", "jul", "aug", "sep", "oct", "nov", "dec"], start=1)}
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
def norm_date(s: str) -> str:
|
| 57 |
+
s = (s or "").strip()
|
| 58 |
+
m = re.match(r"(\d{1,2})[/\-.](\d{1,2})[/\-.](\d{2,4})", s)
|
| 59 |
+
if m:
|
| 60 |
+
d, mo, y = m.groups()
|
| 61 |
+
y = "20" + y if len(y) == 2 else y
|
| 62 |
+
return f"{int(y):04d}-{int(mo):02d}-{int(d):02d}"
|
| 63 |
+
m = re.match(r"(\d{4})-(\d{2})-(\d{2})", s)
|
| 64 |
+
if m:
|
| 65 |
+
return s[:10]
|
| 66 |
+
m = re.match(r"(\d{1,2})\s+([A-Za-z]{3,})\s+(\d{4})", s)
|
| 67 |
+
if m:
|
| 68 |
+
d, mon, y = m.groups()
|
| 69 |
+
mo = _MONTHS.get(mon[:3].lower())
|
| 70 |
+
if mo:
|
| 71 |
+
return f"{int(y):04d}-{mo:02d}-{int(d):02d}"
|
| 72 |
+
return s
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
# ---------------- header synonyms ----------------
|
| 76 |
+
|
| 77 |
+
_CANON = {
|
| 78 |
+
"date": ["transaction date", "txn date", "value date", "posting date", "trans date", "date"],
|
| 79 |
+
"balance": ["running balance", "closing balance", "balance amount", "balance"],
|
| 80 |
+
"debit": ["debit amount", "withdrawals", "withdrawal", "money out", "paid out", "debit", "dr"],
|
| 81 |
+
"credit": ["credit amount", "deposits", "deposit", "money in", "paid in", "credit", "cr"],
|
| 82 |
+
"amount": ["amount", "value"],
|
| 83 |
+
"description": ["description", "details", "narrative", "particulars", "memo", "reference", "remarks", "transaction"],
|
| 84 |
+
}
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
def map_header(cell: str) -> str | None:
|
| 88 |
+
c = (cell or "").strip().lower()
|
| 89 |
+
if not c:
|
| 90 |
+
return None
|
| 91 |
+
# whole-word match so short tokens like "cr"/"dr" don't hit "des(cr)iption"/"a(dr)ess"
|
| 92 |
+
for key in ("date", "balance", "debit", "credit", "amount", "description"):
|
| 93 |
+
for syn in _CANON[key]:
|
| 94 |
+
if re.search(r"\b" + re.escape(syn) + r"\b", c):
|
| 95 |
+
return key
|
| 96 |
+
return None
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
# ---------------- model ----------------
|
| 100 |
+
|
| 101 |
+
@dataclass
|
| 102 |
+
class Transaction:
|
| 103 |
+
date: str
|
| 104 |
+
description: str
|
| 105 |
+
debit: float | None
|
| 106 |
+
credit: float | None
|
| 107 |
+
balance: float | None
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
@dataclass
|
| 111 |
+
class ExtractedStatement:
|
| 112 |
+
account: str | None = None
|
| 113 |
+
account_holder: str | None = None
|
| 114 |
+
period: str | None = None
|
| 115 |
+
opening_balance: float | None = None
|
| 116 |
+
closing_balance: float | None = None
|
| 117 |
+
transactions: list[Transaction] = field(default_factory=list)
|
| 118 |
+
reconciles: bool | None = None
|
| 119 |
+
reconciliation_detail: str = ""
|
| 120 |
+
# structured reconciliation result (each check is True/False/None=absent)
|
| 121 |
+
running_balance_ok: bool | None = None
|
| 122 |
+
stated_summary_ok: bool | None = None
|
| 123 |
+
# "verified" (all present checks pass) | "review" (checks disagree) |
|
| 124 |
+
# "failed" (all present checks fail) | "unverified" (nothing to check)
|
| 125 |
+
reconcile_status: str = "unverified"
|
| 126 |
+
|
| 127 |
+
@property
|
| 128 |
+
def empty(self) -> bool:
|
| 129 |
+
return not (self.transactions or self.opening_balance is not None
|
| 130 |
+
or self.closing_balance is not None or self.account)
|
| 131 |
+
|
| 132 |
+
|
| 133 |
+
# ---------------- the converter ----------------
|
| 134 |
+
|
| 135 |
+
class BankStatementConverter:
|
| 136 |
+
def __init__(self, pdf_path):
|
| 137 |
+
self.pdf_path = Path(pdf_path)
|
| 138 |
+
self.statements: list[ExtractedStatement] = []
|
| 139 |
+
|
| 140 |
+
def extract(self) -> list[ExtractedStatement]:
|
| 141 |
+
events = []
|
| 142 |
+
with pdfplumber.open(self.pdf_path) as pdf:
|
| 143 |
+
for i, page in enumerate(pdf.pages):
|
| 144 |
+
base = i * 100000.0 # offset so global order == reading order
|
| 145 |
+
text = page.extract_text() or ""
|
| 146 |
+
page_ev = self._events_from_text_page(page) if text.strip() else self._events_from_ocr(i)
|
| 147 |
+
events += [(top + base, pri, kind, val) for (top, pri, kind, val) in page_ev]
|
| 148 |
+
events.sort(key=lambda e: (e[0], e[1])) # by (top, priority)
|
| 149 |
+
self._assemble(events)
|
| 150 |
+
for st in self.statements:
|
| 151 |
+
self._reconcile(st)
|
| 152 |
+
return self.statements
|
| 153 |
+
|
| 154 |
+
# ----- text page (gridlined or borderless) -----
|
| 155 |
+
def _events_from_text_page(self, page):
|
| 156 |
+
ev = []
|
| 157 |
+
for ln in page.extract_text_lines():
|
| 158 |
+
kind, val = self._classify(ln["text"])
|
| 159 |
+
if kind:
|
| 160 |
+
ev.append((ln["top"], 0, kind, val))
|
| 161 |
+
tables = page.find_tables()
|
| 162 |
+
if tables:
|
| 163 |
+
for tb in tables:
|
| 164 |
+
ev.append((tb.bbox[1], 1, "table", tb.extract()))
|
| 165 |
+
else: # borderless -> reconstruct from word positions
|
| 166 |
+
rows = self._rows_from_words(page.extract_words(), float(page.width))
|
| 167 |
+
if rows:
|
| 168 |
+
ev.append((rows[0][1], 1, "table", rows[0][0]))
|
| 169 |
+
return ev
|
| 170 |
+
|
| 171 |
+
# ----- scanned page (OCR) -----
|
| 172 |
+
def _events_from_ocr(self, page_index):
|
| 173 |
+
# pytesseract has a Py3.14 stderr-decode bug -> call tesseract CLI (TSV) directly
|
| 174 |
+
import csv as _csv
|
| 175 |
+
import io
|
| 176 |
+
import subprocess
|
| 177 |
+
import fitz
|
| 178 |
+
|
| 179 |
+
doc = fitz.open(self.pdf_path)
|
| 180 |
+
pix = doc[page_index].get_pixmap(dpi=300)
|
| 181 |
+
# write the temp image next to the PDF (tesseract/leptonica can't read /tmp here)
|
| 182 |
+
png = self.pdf_path.parent / f".ocr_p{page_index}.png"
|
| 183 |
+
png.write_bytes(pix.tobytes("png"))
|
| 184 |
+
try:
|
| 185 |
+
proc = subprocess.run(["tesseract", str(png), "stdout", "tsv"],
|
| 186 |
+
stdout=subprocess.PIPE, stderr=subprocess.DEVNULL)
|
| 187 |
+
tsv = proc.stdout.decode("utf-8", "ignore") # don't decode tesseract's binary stderr
|
| 188 |
+
except FileNotFoundError:
|
| 189 |
+
return []
|
| 190 |
+
finally:
|
| 191 |
+
png.unlink(missing_ok=True)
|
| 192 |
+
words = []
|
| 193 |
+
for row in _csv.DictReader(io.StringIO(tsv), delimiter="\t", quoting=_csv.QUOTE_NONE):
|
| 194 |
+
txt = (row.get("text") or "").strip()
|
| 195 |
+
try:
|
| 196 |
+
conf = float(row.get("conf", "-1"))
|
| 197 |
+
except (TypeError, ValueError):
|
| 198 |
+
conf = -1
|
| 199 |
+
if txt and conf > 30:
|
| 200 |
+
x, y = int(row["left"]), int(row["top"])
|
| 201 |
+
w, h = int(row["width"]), int(row["height"])
|
| 202 |
+
words.append({"text": txt, "x0": x, "x1": x + w, "top": y, "bottom": y + h})
|
| 203 |
+
ev = []
|
| 204 |
+
# markers from OCR text lines
|
| 205 |
+
for line_text, top in self._lines_from_words(words):
|
| 206 |
+
kind, val = self._classify(line_text)
|
| 207 |
+
if kind:
|
| 208 |
+
ev.append((top, 0, kind, val))
|
| 209 |
+
rows = self._rows_from_words(words, float(pix.width))
|
| 210 |
+
if rows:
|
| 211 |
+
ev.append((rows[0][1], 1, "table", rows[0][0]))
|
| 212 |
+
return ev
|
| 213 |
+
|
| 214 |
+
# ----- marker classification -----
|
| 215 |
+
@staticmethod
|
| 216 |
+
def _classify(text):
|
| 217 |
+
if ":" not in text:
|
| 218 |
+
return None, None
|
| 219 |
+
label, _, val = text.partition(":")
|
| 220 |
+
L = label.strip().lower()
|
| 221 |
+
val = val.strip()
|
| 222 |
+
if "holder" in L:
|
| 223 |
+
return "holder", val
|
| 224 |
+
if "period" in L:
|
| 225 |
+
return "period", val
|
| 226 |
+
if any(k in L for k in ("opening balance", "balance brought forward", "brought forward",
|
| 227 |
+
"opening", "beginning balance", "b/f")):
|
| 228 |
+
return "opening", val
|
| 229 |
+
if any(k in L for k in ("closing balance", "balance carried forward", "carried forward",
|
| 230 |
+
"closing", "ending balance", "c/f")):
|
| 231 |
+
return "closing", val
|
| 232 |
+
if "account" in L:
|
| 233 |
+
return "account", val
|
| 234 |
+
return None, None
|
| 235 |
+
|
| 236 |
+
# ----- word-position table reconstruction (borderless + OCR) -----
|
| 237 |
+
def _lines_from_words(self, words):
|
| 238 |
+
if not words:
|
| 239 |
+
return []
|
| 240 |
+
ws = sorted(words, key=lambda w: (w["top"], w["x0"]))
|
| 241 |
+
heights = [w["bottom"] - w["top"] for w in ws if w["bottom"] > w["top"]]
|
| 242 |
+
ytol = (statistics.median(heights) if heights else 8) * 0.7
|
| 243 |
+
lines, cur, top = [], [ws[0]], ws[0]["top"]
|
| 244 |
+
for w in ws[1:]:
|
| 245 |
+
if abs(w["top"] - top) <= ytol:
|
| 246 |
+
cur.append(w)
|
| 247 |
+
else:
|
| 248 |
+
lines.append(cur)
|
| 249 |
+
cur, top = [w], w["top"]
|
| 250 |
+
lines.append(cur)
|
| 251 |
+
out = []
|
| 252 |
+
for ln in lines:
|
| 253 |
+
ln.sort(key=lambda w: w["x0"])
|
| 254 |
+
out.append((" ".join(w["text"] for w in ln), ln[0]["top"]))
|
| 255 |
+
return out
|
| 256 |
+
|
| 257 |
+
def _rows_from_words(self, words, page_width):
|
| 258 |
+
"""Reconstruct a table from word positions. Columns are found from the
|
| 259 |
+
vertical whitespace gaps in the table region (robust for right-aligned
|
| 260 |
+
numbers). Returns [(rows, table_top)] or []."""
|
| 261 |
+
if not words:
|
| 262 |
+
return []
|
| 263 |
+
ws = sorted(words, key=lambda w: (w["top"], w["x0"]))
|
| 264 |
+
heights = [w["bottom"] - w["top"] for w in ws if w["bottom"] > w["top"]]
|
| 265 |
+
ytol = (statistics.median(heights) if heights else 8) * 0.7
|
| 266 |
+
lines, cur, top = [], [ws[0]], ws[0]["top"]
|
| 267 |
+
for w in ws[1:]:
|
| 268 |
+
if abs(w["top"] - top) <= ytol:
|
| 269 |
+
cur.append(w)
|
| 270 |
+
else:
|
| 271 |
+
lines.append(sorted(cur, key=lambda w: w["x0"]))
|
| 272 |
+
cur, top = [w], w["top"]
|
| 273 |
+
lines.append(sorted(cur, key=lambda w: w["x0"]))
|
| 274 |
+
|
| 275 |
+
hdr_idx = None
|
| 276 |
+
for i, ln in enumerate(lines):
|
| 277 |
+
t = " ".join(w["text"] for w in ln).lower()
|
| 278 |
+
if "date" in t and any(k in t for k in
|
| 279 |
+
("balance", "amount", "credit", "debit", "deposit", "withdraw",
|
| 280 |
+
"description", "details")):
|
| 281 |
+
hdr_idx = i
|
| 282 |
+
break
|
| 283 |
+
if hdr_idx is None:
|
| 284 |
+
return []
|
| 285 |
+
|
| 286 |
+
region = lines[hdr_idx:] # header + data rows only
|
| 287 |
+
# merge word x-intervals into column "bands" separated by whitespace gaps
|
| 288 |
+
intervals = sorted((w["x0"], w["x1"]) for ln in region for w in ln)
|
| 289 |
+
merge_tol = max(4.0, 0.006 * page_width) # < inter-column gap, > intra-word spaces
|
| 290 |
+
bands = []
|
| 291 |
+
bx0, bx1 = intervals[0]
|
| 292 |
+
for x0, x1 in intervals[1:]:
|
| 293 |
+
if x0 - bx1 <= merge_tol:
|
| 294 |
+
bx1 = max(bx1, x1)
|
| 295 |
+
else:
|
| 296 |
+
bands.append((bx0, bx1)); bx0, bx1 = x0, x1
|
| 297 |
+
bands.append((bx0, bx1))
|
| 298 |
+
ncol = len(bands)
|
| 299 |
+
bounds = [-math.inf] + [(bands[j][1] + bands[j + 1][0]) / 2 for j in range(ncol - 1)] + [math.inf]
|
| 300 |
+
|
| 301 |
+
def assign(line):
|
| 302 |
+
row = [""] * ncol
|
| 303 |
+
for w in line:
|
| 304 |
+
cx = (w["x0"] + w["x1"]) / 2
|
| 305 |
+
for j in range(ncol):
|
| 306 |
+
if bounds[j] <= cx < bounds[j + 1]:
|
| 307 |
+
row[j] = (row[j] + " " + w["text"]).strip()
|
| 308 |
+
break
|
| 309 |
+
return row
|
| 310 |
+
|
| 311 |
+
rows = [assign(region[0])]
|
| 312 |
+
for ln in region[1:]:
|
| 313 |
+
r = assign(ln)
|
| 314 |
+
if any(r):
|
| 315 |
+
rows.append(r)
|
| 316 |
+
return [(rows, region[0][0]["top"])]
|
| 317 |
+
|
| 318 |
+
@staticmethod
|
| 319 |
+
def _norm_acct(s):
|
| 320 |
+
return re.sub(r"\(cid:\d+\)", "", (s or "")).strip().lower()
|
| 321 |
+
|
| 322 |
+
# ----- assemble sections into statements -----
|
| 323 |
+
def _assemble(self, events):
|
| 324 |
+
cur = None
|
| 325 |
+
|
| 326 |
+
def fresh():
|
| 327 |
+
return ExtractedStatement()
|
| 328 |
+
|
| 329 |
+
for _top, _pri, kind, val in events:
|
| 330 |
+
if kind == "account":
|
| 331 |
+
same = (cur is not None and cur.account is not None
|
| 332 |
+
and self._norm_acct(cur.account) == self._norm_acct(val))
|
| 333 |
+
# a repeated header for the SAME account is a page continuation, not a new statement
|
| 334 |
+
if cur and not cur.empty and not same and (cur.transactions or cur.closing_balance is not None):
|
| 335 |
+
self.statements.append(cur); cur = fresh()
|
| 336 |
+
if cur is None:
|
| 337 |
+
cur = fresh()
|
| 338 |
+
if cur.account is None or not same:
|
| 339 |
+
cur.account = val
|
| 340 |
+
elif kind == "holder":
|
| 341 |
+
cur = cur or fresh(); cur.account_holder = val
|
| 342 |
+
elif kind == "period":
|
| 343 |
+
cur = cur or fresh(); cur.period = val
|
| 344 |
+
elif kind == "opening":
|
| 345 |
+
# only split when the previous statement actually closed; a repeated
|
| 346 |
+
# "brought forward" mid-run is a page continuation, not a new statement
|
| 347 |
+
if cur and cur.closing_balance is not None:
|
| 348 |
+
self.statements.append(cur); cur = fresh()
|
| 349 |
+
cur = cur or fresh()
|
| 350 |
+
if cur.opening_balance is None:
|
| 351 |
+
cur.opening_balance = parse_money(val)
|
| 352 |
+
elif kind == "closing":
|
| 353 |
+
cur = cur or fresh(); cur.closing_balance = parse_money(val)
|
| 354 |
+
elif kind == "table":
|
| 355 |
+
summ = self._summary_balances(val) # Beginning/Ending balance table?
|
| 356 |
+
if summ:
|
| 357 |
+
if cur and cur.transactions: # new summary block => new statement
|
| 358 |
+
self.statements.append(cur); cur = fresh()
|
| 359 |
+
cur = cur or fresh()
|
| 360 |
+
cur.opening_balance, cur.closing_balance = summ
|
| 361 |
+
else:
|
| 362 |
+
cur = cur or fresh()
|
| 363 |
+
cur.transactions += self._parse_rows(val)
|
| 364 |
+
if cur and not cur.empty:
|
| 365 |
+
self.statements.append(cur)
|
| 366 |
+
|
| 367 |
+
@staticmethod
|
| 368 |
+
def _summary_balances(table):
|
| 369 |
+
"""Detect a Beginning/Ending-balance summary table -> (opening, closing) or None."""
|
| 370 |
+
if not table or len(table) < 2:
|
| 371 |
+
return None
|
| 372 |
+
header = [(c or "").strip().lower() for c in table[0]]
|
| 373 |
+
if any("date" in h for h in header):
|
| 374 |
+
return None
|
| 375 |
+
o = next((i for i, h in enumerate(header) if "beginning balance" in h or "opening balance" in h), None)
|
| 376 |
+
cl = next((i for i, h in enumerate(header) if "ending balance" in h or "closing balance" in h), None)
|
| 377 |
+
if o is None or cl is None:
|
| 378 |
+
return None
|
| 379 |
+
vals = table[1]
|
| 380 |
+
op = parse_money(vals[o]) if o < len(vals) else None
|
| 381 |
+
clo = parse_money(vals[cl]) if cl < len(vals) else None
|
| 382 |
+
return (op, clo) if op is not None and clo is not None else None
|
| 383 |
+
|
| 384 |
+
def _parse_rows(self, table):
|
| 385 |
+
if not table or len(table) < 2:
|
| 386 |
+
return []
|
| 387 |
+
header = [(c or "").strip() for c in table[0]]
|
| 388 |
+
idx = {}
|
| 389 |
+
for j, h in enumerate(header):
|
| 390 |
+
key = map_header(h)
|
| 391 |
+
if key and key not in idx:
|
| 392 |
+
idx[key] = j
|
| 393 |
+
if "date" not in idx or not ({"balance", "amount", "debit", "credit"} & idx.keys()):
|
| 394 |
+
return []
|
| 395 |
+
out = []
|
| 396 |
+
for row in table[1:]:
|
| 397 |
+
cells = [(c or "").strip() for c in row]
|
| 398 |
+
if "date" not in idx or idx["date"] >= len(cells):
|
| 399 |
+
continue
|
| 400 |
+
raw_date = cells[idx["date"]]
|
| 401 |
+
if not raw_date or not re.search(r"\d", raw_date):
|
| 402 |
+
continue
|
| 403 |
+
debit = credit = None
|
| 404 |
+
if "debit" in idx or "credit" in idx:
|
| 405 |
+
debit = parse_money(cells[idx["debit"]]) if "debit" in idx and idx["debit"] < len(cells) else None
|
| 406 |
+
credit = parse_money(cells[idx["credit"]]) if "credit" in idx and idx["credit"] < len(cells) else None
|
| 407 |
+
elif "amount" in idx and idx["amount"] < len(cells):
|
| 408 |
+
amt = parse_money(cells[idx["amount"]])
|
| 409 |
+
if amt is not None:
|
| 410 |
+
if amt < 0:
|
| 411 |
+
debit = -amt
|
| 412 |
+
else:
|
| 413 |
+
credit = amt
|
| 414 |
+
balance = parse_money(cells[idx["balance"]]) if "balance" in idx and idx["balance"] < len(cells) else None
|
| 415 |
+
out.append(Transaction(norm_date(raw_date),
|
| 416 |
+
cells[idx["description"]] if "description" in idx and idx["description"] < len(cells) else "",
|
| 417 |
+
debit, credit, balance))
|
| 418 |
+
return out
|
| 419 |
+
|
| 420 |
+
def _reconcile(self, st, tol=0.02):
|
| 421 |
+
txns = st.transactions
|
| 422 |
+
credits = sum(t.credit or 0.0 for t in txns)
|
| 423 |
+
debits = sum(t.debit or 0.0 for t in txns)
|
| 424 |
+
parts = []
|
| 425 |
+
|
| 426 |
+
# (1) running-balance integrity — validates the balance column directly (label-free)
|
| 427 |
+
integrity = None
|
| 428 |
+
if txns and all(t.balance is not None for t in txns):
|
| 429 |
+
derived_open = round(txns[0].balance - ((txns[0].credit or 0) - (txns[0].debit or 0)), 2)
|
| 430 |
+
prev, bad = derived_open, 0
|
| 431 |
+
for t in txns:
|
| 432 |
+
if abs(round(prev + (t.credit or 0) - (t.debit or 0), 2) - t.balance) > tol:
|
| 433 |
+
bad += 1
|
| 434 |
+
prev = t.balance
|
| 435 |
+
integrity = (bad == 0)
|
| 436 |
+
parts.append(f"running-balance {'✓' if integrity else f'✗ ({bad} bad)'} "
|
| 437 |
+
f"(derived {derived_open:,.2f}→{txns[-1].balance:,.2f})")
|
| 438 |
+
|
| 439 |
+
# (2) stated-summary match — opening + credits - debits == closing
|
| 440 |
+
summary = None
|
| 441 |
+
if st.opening_balance is not None and st.closing_balance is not None and txns:
|
| 442 |
+
summary = abs((st.opening_balance + credits - debits) - st.closing_balance) <= tol
|
| 443 |
+
parts.append(f"stated-summary {'✓' if summary else '✗'} "
|
| 444 |
+
f"({st.opening_balance:,.2f}+{credits:,.2f}-{debits:,.2f} vs {st.closing_balance:,.2f})")
|
| 445 |
+
|
| 446 |
+
st.running_balance_ok = integrity
|
| 447 |
+
st.stated_summary_ok = summary
|
| 448 |
+
present = [c for c in (integrity, summary) if c is not None]
|
| 449 |
+
if not present:
|
| 450 |
+
st.reconciles = None
|
| 451 |
+
st.reconcile_status = "unverified"
|
| 452 |
+
st.reconciliation_detail = "No balance column or stated totals to reconcile."
|
| 453 |
+
else:
|
| 454 |
+
# `reconciles` stays "either proof is enough" for backwards-compat;
|
| 455 |
+
# `reconcile_status` is the precise signal the UI uses.
|
| 456 |
+
st.reconciles = any(present)
|
| 457 |
+
if all(present):
|
| 458 |
+
st.reconcile_status = "verified"
|
| 459 |
+
elif any(present):
|
| 460 |
+
st.reconcile_status = "review" # one check passes, another disagrees
|
| 461 |
+
else:
|
| 462 |
+
st.reconcile_status = "failed"
|
| 463 |
+
st.reconciliation_detail = " | ".join(parts)
|
| 464 |
+
|
| 465 |
+
# ----- export -----
|
| 466 |
+
def to_excel(self, out_path):
|
| 467 |
+
wb = Workbook(); wb.remove(wb.active)
|
| 468 |
+
for n, st in enumerate(self.statements, 1):
|
| 469 |
+
ws = wb.create_sheet(f"Account{n}")
|
| 470 |
+
ws.append(["Date", "Description", "Debit", "Credit", "Balance"])
|
| 471 |
+
for t in st.transactions:
|
| 472 |
+
ws.append([t.date, t.description, t.debit, t.credit, t.balance])
|
| 473 |
+
wb.save(out_path); return Path(out_path)
|
| 474 |
+
|
| 475 |
+
def to_csv(self, out_path):
|
| 476 |
+
with open(out_path, "w", newline="") as f:
|
| 477 |
+
w = csv.writer(f)
|
| 478 |
+
w.writerow(["Account", "Date", "Description", "Debit", "Credit", "Balance"])
|
| 479 |
+
for st in self.statements:
|
| 480 |
+
for t in st.transactions:
|
| 481 |
+
w.writerow([st.account or "", t.date, t.description, t.debit, t.credit, t.balance])
|
| 482 |
+
return Path(out_path)
|
| 483 |
+
|
| 484 |
+
|
| 485 |
+
# ---------------- demo ----------------
|
| 486 |
+
|
| 487 |
+
def _print(statements):
|
| 488 |
+
flag = {True: "✅ PASS", False: "❌ FAIL", None: "⚠️ N/A"}
|
| 489 |
+
print("=" * 60)
|
| 490 |
+
print(f"STATEMENTS FOUND: {len(statements)}")
|
| 491 |
+
for n, st in enumerate(statements, 1):
|
| 492 |
+
print("-" * 60)
|
| 493 |
+
print(f"[{n}] account={st.account} holder={st.account_holder}")
|
| 494 |
+
print(f" open/close: {st.opening_balance} / {st.closing_balance} txns: {len(st.transactions)}")
|
| 495 |
+
print(f" reconcile : {flag[st.reconciles]} | {st.reconciliation_detail}")
|
| 496 |
+
print("=" * 60)
|
| 497 |
+
|
| 498 |
+
|
| 499 |
+
if __name__ == "__main__":
|
| 500 |
+
pdf = Path(sys.argv[1]) if len(sys.argv) > 1 else Path(__file__).parent / "sample_statement.pdf"
|
| 501 |
+
conv = BankStatementConverter(pdf)
|
| 502 |
+
sts = conv.extract()
|
| 503 |
+
_print(sts)
|
| 504 |
+
conv.to_excel(pdf.with_suffix(".out.xlsx"))
|
| 505 |
+
conv.to_csv(pdf.with_suffix(".out.csv"))
|
app/main.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Export Bank Statement — extractor service.
|
| 2 |
+
|
| 3 |
+
A thin, stateless FastAPI wrapper over the proven extraction engine:
|
| 4 |
+
PDF in -> reconciled statement data out. No storage, no auth (the web app's
|
| 5 |
+
API layer owns quota/auth/billing); this service only extracts.
|
| 6 |
+
"""
|
| 7 |
+
from __future__ import annotations
|
| 8 |
+
|
| 9 |
+
from fastapi import FastAPI
|
| 10 |
+
from fastapi.middleware.cors import CORSMiddleware
|
| 11 |
+
|
| 12 |
+
from app.api.convert import router as convert_router
|
| 13 |
+
from app.core.config import ALLOWED_ORIGINS
|
| 14 |
+
|
| 15 |
+
app = FastAPI(title="Export Bank Statement — Extractor", version="0.1.0")
|
| 16 |
+
|
| 17 |
+
app.add_middleware(
|
| 18 |
+
CORSMiddleware,
|
| 19 |
+
allow_origins=ALLOWED_ORIGINS,
|
| 20 |
+
allow_methods=["GET", "POST", "OPTIONS"],
|
| 21 |
+
allow_headers=["*"],
|
| 22 |
+
)
|
| 23 |
+
|
| 24 |
+
app.include_router(convert_router)
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
@app.get("/health")
|
| 28 |
+
def health() -> dict:
|
| 29 |
+
return {"ok": True, "service": "extractor", "version": "0.1.0"}
|
nixpacks.toml
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Railway / Nixpacks build for the FastAPI extractor — installs the Tesseract
|
| 2 |
+
# system binary (for scanned PDFs) without a Dockerfile. Python deps come from
|
| 3 |
+
# requirements.txt automatically; the start command is in the Procfile.
|
| 4 |
+
[phases.setup]
|
| 5 |
+
aptPkgs = ["tesseract-ocr"]
|
requirements-dev.txt
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
-r requirements.txt
|
| 2 |
+
reportlab
|
| 3 |
+
pytest
|
| 4 |
+
httpx
|
requirements.txt
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
fastapi
|
| 2 |
+
uvicorn[standard]
|
| 3 |
+
python-multipart
|
| 4 |
+
pdfplumber
|
| 5 |
+
openpyxl
|
| 6 |
+
pymupdf
|
tests/fixtures/complex_statement.pdf
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
%PDF-1.4
|
| 2 |
+
%���� ReportLab Generated PDF document (opensource)
|
| 3 |
+
1 0 obj
|
| 4 |
+
<<
|
| 5 |
+
/F1 2 0 R /F2 3 0 R
|
| 6 |
+
>>
|
| 7 |
+
endobj
|
| 8 |
+
2 0 obj
|
| 9 |
+
<<
|
| 10 |
+
/BaseFont /Helvetica /Encoding /WinAnsiEncoding /Name /F1 /Subtype /Type1 /Type /Font
|
| 11 |
+
>>
|
| 12 |
+
endobj
|
| 13 |
+
3 0 obj
|
| 14 |
+
<<
|
| 15 |
+
/BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding /Name /F2 /Subtype /Type1 /Type /Font
|
| 16 |
+
>>
|
| 17 |
+
endobj
|
| 18 |
+
4 0 obj
|
| 19 |
+
<<
|
| 20 |
+
/Contents 11 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 10 0 R /Resources <<
|
| 21 |
+
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
| 22 |
+
>> /Rotate 0 /Trans <<
|
| 23 |
+
|
| 24 |
+
>>
|
| 25 |
+
/Type /Page
|
| 26 |
+
>>
|
| 27 |
+
endobj
|
| 28 |
+
5 0 obj
|
| 29 |
+
<<
|
| 30 |
+
/Contents 12 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 10 0 R /Resources <<
|
| 31 |
+
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
| 32 |
+
>> /Rotate 0 /Trans <<
|
| 33 |
+
|
| 34 |
+
>>
|
| 35 |
+
/Type /Page
|
| 36 |
+
>>
|
| 37 |
+
endobj
|
| 38 |
+
6 0 obj
|
| 39 |
+
<<
|
| 40 |
+
/Contents 13 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 10 0 R /Resources <<
|
| 41 |
+
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
| 42 |
+
>> /Rotate 0 /Trans <<
|
| 43 |
+
|
| 44 |
+
>>
|
| 45 |
+
/Type /Page
|
| 46 |
+
>>
|
| 47 |
+
endobj
|
| 48 |
+
7 0 obj
|
| 49 |
+
<<
|
| 50 |
+
/Contents 14 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 10 0 R /Resources <<
|
| 51 |
+
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
| 52 |
+
>> /Rotate 0 /Trans <<
|
| 53 |
+
|
| 54 |
+
>>
|
| 55 |
+
/Type /Page
|
| 56 |
+
>>
|
| 57 |
+
endobj
|
| 58 |
+
8 0 obj
|
| 59 |
+
<<
|
| 60 |
+
/PageMode /UseNone /Pages 10 0 R /Type /Catalog
|
| 61 |
+
>>
|
| 62 |
+
endobj
|
| 63 |
+
9 0 obj
|
| 64 |
+
<<
|
| 65 |
+
/Author (\(anonymous\)) /CreationDate (D:20260614232541+05'00') /Creator (\(unspecified\)) /Keywords () /ModDate (D:20260614232541+05'00') /Producer (ReportLab PDF Library - \(opensource\))
|
| 66 |
+
/Subject (\(unspecified\)) /Title (\(anonymous\)) /Trapped /False
|
| 67 |
+
>>
|
| 68 |
+
endobj
|
| 69 |
+
10 0 obj
|
| 70 |
+
<<
|
| 71 |
+
/Count 4 /Kids [ 4 0 R 5 0 R 6 0 R 7 0 R ] /Type /Pages
|
| 72 |
+
>>
|
| 73 |
+
endobj
|
| 74 |
+
11 0 obj
|
| 75 |
+
<<
|
| 76 |
+
/Filter [ /ASCII85Decode /FlateDecode ] /Length 3086
|
| 77 |
+
>>
|
| 78 |
+
stream
|
| 79 |
+
GatU6>Beg[&q9SY^k3]63=LN:-#]#945/s8M,'MRGo\j0T#>Lep[$ep!==9cNr[PoE=rMJiF"jpi0tV*'G:,7h7pGV&Xp2mm3P2cU%VPU"W"%BUSba"j+;SO+gEec#c*a%P"Pi%JXCQ=VlpnEJOZF(pieF5&2h`UA'UTAfM\abaP/B3e`Q#fO$@QSmdJN^k/Vu!k2tXbleV<ILM'TDhELr7nGFrhreB.YkfrlgnEL+'Sb#OTkh14b+c<G/hK=&ZUjEoKl`[romuTD18\l95Dp\FY\Crj],?iU;pLmKDl`4XHCg0jl(;&mL)ZXD2k3S^#K\eqBqf;H'%P;jnrOVfM[FcMnkl#D4>]$[)B]"8.lZP;)&Oubs,KJu:k<<_Z];PPH0CKIGDrD3O9VU]?7/:&]]@@$;83+*'Ha3JHmc"0(/"5@C]6Up"H\Z3r<.kfV89nENe_f/*na5keG[&=$-+1V>:>"0gcW>"tK$S)d4,,2Z;H\Yjdm?rc\Rn`AkNW.3V`6u*8o0?KkRANEV`XP:CG>%fH8t=\lG7o<lia(MM@kX7eIj'Pir,j:_-FfjNgb/VDP-?HJ%gD[3<'!A*%O!&-Vs['rqX40+9F(0C"-ENh0hjE)ifb!1+N/4S7nN!dR>dDS94:L25G*j^XoYJoe3p/*<!cN9tOcaMZu&jkci^K64mW=mmL&I]6D\-3MU"Cest*sc\tmk_:qhDQ_>6SN6=LoL'Q)bOFVqY&MF2+oJb$8[AXTFRQ'!`En-mqZele^qk:PWRTj'JmHpErD6[&p`gp_3Ebn/FFOUNA=rXP6%Q(h](P2;Vqtfou?DH,m[])2eoqj_N+AN)AQ9_*DgQ,MmSq7I=8sg@@g.YPe*WAH]#UZcQM?a#!pg?sL8]tM2*-bn<n8':nYFgdKm]bcl=C-l0D^Ub4@Ab!D/;59EN-WbdVD8j#=#SGdGo:G%hd.@#]0euk2c*+s3k18D)p!*!na;JhaXo#sg`(kjX>gCP9-NcsF4c+6TdpKGY!ig^outE'@e]`"D;-&!@uL'RC'hT!@T/&j`gq258l7&&Zp\h@ZPfs>X26?+Hj8Xt+,[[a?;AiUEa_,Ea_AhK5+oOE3lpt83\#5fGEDU3m2B.keGd+',rf<^,57riaMVUT":??Fngu%-oMl&/R4!0Oe]X&$rP`X`Zpo^19^h<@ph@3AG,,ThPXsWba?fO3bQ%\AN0S/2T[,-g.(2-J'@J\+Sd=$8n+Q/A&T)^_'ndJbXmOQ-#iKes)(5Qk;bLgRf=ej;Hl3f^>BhVdr=OM*=3sc9NbPYr$/lcI:5&*/YNO.2]l"?2Ql2@c*kO:a;8U.]SFKJ^j6bo8Ma1=.Q4fZrU'H*@.LtKhPr[Kuk6!cMQj<R@Z,7HG8u>doaQpYX`7)Ml@8lD1Ghq]KTEgBI=-@65D%8T=08!$Bm,gnYB@)3rU/$U<k')HEj4Hu$MI(rWoHEK&VC.cVG&DV8TG,LocsNq>Y*MceraU?Zj\)sc*?k7AfBipVG\C]BjJ`2$d<fpPoO1*p3_^JeSYCp(l>(X[(i45DkGm\K3jG=_rB`dT`abjTHW)rcI^>HAKRa(T@<"ORoju+840gl0j0GQe3jGairNg([<H=jVWKMTI3QQXcW8puEm#$mW^82Q%"RBm(F&-`gb?E7G9IoP(CFOK?4YLqs@7-CHJmi7ak_$V"Q"?%#fmqPC'c<?phWl9LFaa&O3,Z!8:j4/H0>\k-59G(7[0o2T4I)PV$_SfC$[[%1S]3#,_Fc4fSB=-i'&H>87VCQXZTU+KM,Y;`MeroBSPd6L`iP!RgWSLc41_kT:s;gi>JM?R.8^iTJnZk;RI9m%9dI?Ds.S;X5k4O4n>ZD77*?U@J9J=cQYqT_ljS3O<:YeG+)JdO7cAb@>P(bM]m^H<Zq*Sr'uW;f`A?$X"VVQSX6EP5:JL0f]S^PFi<kcJ>"A'3275pC_'10Z]YSYM=65h5H%Ja9^7_*6D]nK%nl.7tjLcn'?J*JmH,?dVO^<\^oNfaXVD0kekY/Vt]%:;l%aDjrc-07kHN@2`e81Vl<DRupn:pF3Go?bQX[*+</jiS#3<l'$(c#!<ZtN11PenVg?,=U$4iaFM-Y`^-@!e_dCA-T0@P&UDDf!F)\n-l:8a0tBRWH8)aUbW*R!;C_"aJD?10o)S.7NKY.bJd*3TP">_ZP"aT7J%8=pFpf_^_#B<0MQ40aYGCcU-N->-<Z6'mOXIF4eBJ1MBml*e0c'm]]30a)aZ%!d<h3kL;od?9`][$b0VT>q53t:(Fal,Y'$I[YS-WP(P-`^bUh;MjNDb;b52nmY$ePkT5\nl-utXl)8t-*-`O:3X-k-_<DqaWa5ndlR-Ud\pb]Hqa;SGJ5[2()5cbbrW&IS3%eCAU4V&"o3=4iLHE'rYeBmdDSF$7=R$ACB"]e@Y;Wh:mZVp#)VftV<L&)CL?J!)b]#p."h^8CEI^ZH9"Ki]Wfg2-9A+2TT0EI*Q;edf\`F^Yb0R8%)JZH^[i=sR\OML&NNS`+iQ?:`Y(*e)_N^5=gORRL>)?1E=V7ed?-<Cu#3HXpZ-$B?Mg.0:"g44ZC/DjZ'f$Y<#ro9t[Uq5Li=lA(Q]\,A/Qags<l=&a?=Wsq:/#KW-B>%mk+1.8o+KgNie/4'itSngP\qn$]'Z[R<VB,$#^EO9#LYs+,H?fDMub0/Q4[lA]7cr2dc<p>50fa?goiIZ#EO+o9.)aV6lC6&r[#YR(K15AIt,)GP#)P[rqcS4Fb'_m?i9S\n&$fO\E,YklpOT6:4<h@Z;7!(Kc_hf7SgfTLg9t/,'(jTdV:%ZAJ@FoE[lZP'T%qcDAblZjG$l.&K/2H"uh2Q\`&iOUdf73AJE7K)f?N^I,p#d7M-ZOiKcdJmDoaGN$:E*__%WSNSJKr-k[a`gbOc>a:5V;+u4=o$uRaTEl0K(8<;PFb:&Mu2r$0GprEoRN$:<'__$Lsg6!=l)+A/uL0I-0)S+gm;'\VK\7HAZOW/0U7:b`h);MG@0C7VM*r2I)dW:qL-fGn4j(/&WmU:0EOH.e[#N>^#!G7'rTnM^LEA>PFe3/(=H!Cbu_+@F1:*(3;rh93U(]O^`[Igm~>endstream
|
| 80 |
+
endobj
|
| 81 |
+
12 0 obj
|
| 82 |
+
<<
|
| 83 |
+
/Filter [ /ASCII85Decode /FlateDecode ] /Length 3211
|
| 84 |
+
>>
|
| 85 |
+
stream
|
| 86 |
+
Gat%&gN"7D&Ui84i.2DqCCK6PGoVtlR9AB?Nbg4aNoNiZC[Fh9=7,IS5ls3$+onBYmFkqi2Ymh##E9dF_Vbs>r;Z@I6C&jAF3=k+AGh^3726]LA/K7aF2K5#H>8s^MJ,;`e4RUP^eD;sg3>r<YY&t'V%m0Zo&F(9hr/o_3hj0^SnUo*F%:\,+ht_mVr8.o0(TEZ0@Pe<.=@Lp28fhl`c'oklVBa[r0<5UOn$t#N2+f,0d4=0p[aHLs8MieO71DE5tX_.dH]+<rpf[Eo2rsG2+ho-Y'tcoB+aJUAVOn%00fF7l20YaEO-$01$;R!S6_Y(rJ$.fJdL8k+7\OaZoJV,4SAUCm;O94nb#EHMg4F,BltCI?rc0rBMSBb\Qh9\KCG%-aZ.SRi14tM3a[+S7SkJ.T($'0K#:O8A9BBK+i]EjEArr'_CpuK!u_+]rHY^nS,I]n]jTVeE]C1\KUaX@(5t$-(&@r/N8$*:Tr9c%-[^V+HM!rl?Gk6odC/<A(47IrLu/a'm<8)`Xq!O6^0dI5Yl*PDYeSV0::=h+57?3`AZ4T?g/iZ]G4OirVj4$c]%YlNN-<.C2lC(AP4c'7N8uk\mpHCfLXY&]foBig)K(P=<j]A,B+*<_Dl>5F-gk&`NI1509ufOM5q"8=V0a*SLbE@$r*(X($VNJ,Nd`,"Q/sffr/.]*:*-P&N8lTdNOH?sJP\.:8,RUY,3+5.,@3/1!$RUcf"kha7l#PK>@sZ_;:e&:nhU#E=O3s*/`NLtKG/@t-TgKRk^SRO]BXsO1PnX"DHLgXrFmBI8FbG/aSOrU%"CX5F(6J2#^Z,A&bIp/6?gFol3iYmU7Lf)G?P+9>+06)?:6m6n!A!@]aDdtGroi'1lO(C(XegXmi&R.T*jsB'@:>6<5.iEOZ)2bfaP4:=)"/&CbsDBKO5b._jN4"D6eKBO67%/_aVotf=#WF9pA@<TI.g$QA+T@*(dHC"d!cA44dU^WUMdfl7)nOQcV7P,C[=O^KD*E2$j$TrX@^hZJL7$ApJkdb$]8m44PfcU:V=jQ#mB(*0dY;i!JjH-OnRW<VH!`"\d6Q+7\NCgRfP(+h$)6A9BjPa`4tb@j(EA_fctS?@fbr2L>#jg]R%FK.u>U$NjtS/NhC:IR4-bDghR@dp0PEI`-0uj)>p^,EU`7$s;nCGS7'prB:RY/tV+ES$rMQ9a3["DCSVMdU%P5^dag_S;C!S=^QI9YI_Nn4R*4`>C68+E#RW*0;f[J5HOUs"50;2OQ13m9XE0)Vt2Hb>$)9?h'uPP]##EbEQ1'L2^QX^]WHGsmte04l9\_3W`bN-=A9=,=ZijK[r'Z4kg=0*d%G!QANDrU.r@A3G/PJ_j8GD4%a3+?+E2pnQ],R71![<0+=oPfNNArH83cqQ%@>pLfj&^VWo)0uk8ae9KNtV*=&J/Y9_tc31PHP4f"+nmSfB=MVr;)kWd?)LW`?d,7l]<iB.0hO(@bkVBC_)XB+OW"fs/uR9pDC`=DA5a[4<[a;AQB-.=-)4Z,0Csl3:m&3T+p)A_&9c5gV)mVGt,MD2*b4LR%S8)bS'#BIAklG$^Ya*@%,#&ab6)dY<`5Xd:Wna\V-$GZHFm'((LYcpj(8+4UQ.W(Pf(CcTb7*V$O(*D0qsWnX8?b42$7Jo.1>U<ZsP-cEWiB7_m?UTJ%(D5NgN4QQ)3V.T+U7hG=aG^q<u@DCXbLMO*<9@56AUTYU/3#cZ;`sq^<*X,G;G^$:C=D`O\)'s-sPUCjlq\#o/APB/`QCL7L6hNfP@I>T!AP:e)UP">u2<D6kPN;7;b%>[Tq!p-Df+dD%/_$*8Re,+D1ekB>QZRGR9,cf\n!&QKk7ujj:E?o)]G&$')q<+^<pH8"n*$]j4%W@&ZrHOc#&?U/C[XM'ClPiF04Q$mE')a-KdCr4na6;G,&?r927X`^n1i_?D6"0Z>ufH;9t#!]F-_\aqZZL2^0i6e[B`5gfP[1(.8T_R\Rqr+X`?!R!mt%WH!9%SLp->,dsD3,%nbF,N'`AfnQ'_<%7FQJ?##j&gnQ[X_%%6:[0i:WYAm>Z+knSRhIUhjMl:JK$^cUNANQtSf;_rKA%%;]\u!M8f3NZ*C?nR;'0]s=OnJGTJshr[O8.^MPYe<*=h7d4ZrsI$Q=^KmQf)+dG2<f!IE.VK]XHbR7<tA*\g;a/[1#!6c[fASG4\_,SS!IH>BiI!9%oRl&;Ui;#U?#%WRYnA7WFpNJOoS$]*.';V,4:/4o'[mY+25p[DsN+ZK13]17]or#=4?\Nr:jqdF90$]()@+p:XuJBoBEkau25VRkIV_^.(20oX88Retii<][Bh:C!1+l%G"a0?@5%t\Lq+/QKEc4cO!Z:nM&SVUMcQb]&$iVI-S/b@NP(<\0E*H>I%H+jDHrIm_W8kne/p"dS4I[mH=\]08%M_RRte/=&SYr]&oB4%kF0`_cG5+arZJL@S8]qD4WWXgp_CMUTA`CC0>4Qi_k,&2:5Q@lW4b?YgG:_FbINqpLA\"rT<aGIlNYb@0_hnp@rLOpRp]@R=ZekDMOQQ""W(Vfj%1tP!@UP4T+]DqDqkhdq'$T#k*!9hr0sZoqu/On[Rsf!u^MoRAQ2s^(/.AUWjUM,-A.nE1\[ms7A?s8o5(?:iE,CLi.mtX&HNb)<5kGX,$qH:mRF(.R*-m2VY1_4Z8a+=Y7u/p6b!hNpp4\)Qo]uQ$2(bTf!4bW(X!tn5NAn4u8S[``q_CLK<cBbnP'I\:93>\(LD"eP\*6Es@aVD&!a=e?AAZ(:_UPWQN(R"oef,rH:OciuYMgl//DN)lguOpqsmcdn^(`n98oeRKN^+b<CiVJE?XLoYh45)*dmnDFY6(s1X@;=Prn+_>q@`\e\,].\fZ?9LD3=D<!;6/r44t#US/HCL%P$n-S>L1b`eoZFF>19ZF'PVD(E%0pr?&BN!MeKHZdp(.9"6M.^7M@5;PhKb9&QZ<8#QLaJ[H&Vass=VtS'-BWuoL1k%A(i0OH\L`[n7$Ga0#X^H5/;u8NPK^lL6T"<aN%dpPl*E(\&5>%j,7Mt1Z7s-,9e$Mq%+-?+0Z"Aq/.[A-@0i3R&Vau)=VtS'-Coi&L1l`Q(h&4ISqRc2ai&TC7MqlAARU68RS4Jd(nsT5@>#em6l&6/U5@u?YUL!Q7H#?39Wg.3OugqR8*cWtmj"hmdjKI,W>2ENb#>HCBLd'bPmZn,9t="fqn(#nEq;N__4tH~>endstream
|
| 87 |
+
endobj
|
| 88 |
+
13 0 obj
|
| 89 |
+
<<
|
| 90 |
+
/Filter [ /ASCII85Decode /FlateDecode ] /Length 3237
|
| 91 |
+
>>
|
| 92 |
+
stream
|
| 93 |
+
Gat%&99U=*%DL/li'E.:eYWYeH.5AJ@qG=:WQ4_njuD4^\(9ESEE3!e5`%m)mgdI2Ep_Ql^'=+i+:,1jR(tq*q>[@rN85!1boJL"D"r^8k*c``edj_>\UfE5JQm&GAk$g9N)o]nW]<U(2D?b[dfpXj:)Y/*pOD"H_rmQ\b^eY<(rK[4BAYq[?XBGRp]iO?E@#[m@:^>kJ'=Zkk+lY-=8&!R`ugrRb(a9VnEQ&Oc]sc97"5.(Vse!lrSu)ihk#<_.E/DHqT0ENq"!HjX;77/XamXVn"9V_"FJ+A'o'N5++O1_mF,"K`<D]u6^9ZfM\0oHKRP/"1_'Ej^[HrimH+LX*HpfIE-ko^2TK6IfX&J0%07F_Ajm%ZAC+k"TfR$*pRsiQC@83XN.2%?4:_eCBG#paClhm+[Q5/U0EbX+LO$NgI_@c7X+@DMgWdN?ZfO>Fl+TC<5I0[\AuF1!cp:gb@%XPQC_q]CE,MmUph(.=E\E[q/I$jg*CmA$hWYDMb*h<(I^:-m;h#k@EOf3sqpfT+QE/Qp@6_\QfA7BJ?uCK^g^VBT1Ph^6;Z^X:L$SqMW;FHe&G(hkY&aSJL`1>EYKY6bjpAd`D#6"%)TC7]j`#Zu3*]Nh8IqQ'g;sW?3Z,o2`/%IkMQ_$F(5)%6_<k0,D0=):da.!En@O._=Y3E4]>Y&jgVfaL*mXQTWP?oubJKPcQhkHp[a]#e"gS\jbJHZ-Znjn!:12Wa<SI#4%g3*IR6s'pf:-uRFha"-rGHmMn/Y_OcGO@;qiDJ]Fi!8WHW`mA,Ps=U_K<p%03P(R7sGTVMnp$YYtSM0'*<3H7mGsf]+Z+n9N_*oi)ToY0^,(J7E8+D,@1F:M*+5>)"S\!Sgg06A&O:;I`iLHQcC3<r$uhIrgFIQS&[\P+)^rhH\G+@A8hA4O[o4f_JT#8Jd:1:o44c)k'<bVmk3p08=-^GKEkqsJC>HBNOQTu"lX+b:AFF$]0&6rj(u-hg$3N;ZK#med>lDjdp]kJHQKET-lc4H%+mo3)J:1PM0hG]=<b.b+QEJj(Lj)6C,;O]b@VmUB5IEp-"f"6F=ZJ7!>hmp=<WDsas6a#%tGgu4I3rDqC5Kd0U$t&a%_&-MP],!h.D.d^2/;Sk:?c)S\lb0a#aFUb5'.eD$&.I3!#C<\8,Y=_LoeL`X"2t3rn?b-331mF\#halW[@p2AW7q?OlInob+u.5$NuH+"%qu.[/KCROg_M,G4L.b\Z/Y:;biX-/Gp!j*KTIZMjS^473;)XY!OqR<c6JJ.=Xt/daC'bS._-I'a0^'@(UXCWr?G/nPl?2`pQKSaVLceTa+DPJp/Pk:uM:pNV4R>T@B!j$Cg??t\hA4!,Q>IV..>R!FN!"2J%f"p!%0Xi-P27/BY_Y+"u=*i/8+[obQ?k\EI)bq/^;9RPB*a%6SkI6:!6VNg5Vk.3s?U0kBU=gA<2FC&@DXKK7LAdPGKP#(k_bf?:aVrdo*LR$22Gl>JY,_$;j<QDL!Sb>H6)8]G!H\(6]`+:IXOi=itA%go<fj9Tda4cs"7AJ/?o^>PEk&)!;68&k'4eIjo6`C,6iY`7>3j#YBW.d=)7b#l2+eJ53Q4=j:<JpFm45$V&c6HbC-c#9!i_B2m]PD>9I-oKSfi6henS[OZ:3]Ipa5rW#3Y210GJDpA0<^<9X_\R41pXSo\Q3^>XOYs-8ociJ;l*\M@\<1`TE>jG4)4+Vl=fiap'JLe%pLr>fX(J"nFki7''Tqi27r2O86n5h?=@ACEEU!6]!^!#^.-pTMSfPQ3c8`X]Me<3e._h#*$3CAGIZIFH\S4X>]-]t``37+=(OMNSM[/nR;qIk+`iW9T16a.DggQ;FhSG/Pj"XR!iM@K0NPCTm7dKcs6RE6Eq[A7Ni@$CPDsfr94(G&[-#^blH!Gilo53h;2)=bQFciOS27n8b[<N9N@VCX8fAu91`9OHJ\REfPtXE9/+gq)8<$QJl6.fdWp%Apq?^N@UlFPA#bkGKjD[o'.cHshMg]Cq>dQ7,iBbR\J`j2ea[eMeZ*UspH$=K&*X@X@*Uh*TNP;6:#h[.`o&iP.j7/)q1lgUsoX0&arIhB*2&N+5p,s:n&Pkb;EN^[4XY`Omd+B:l`hfMrWIpHlM9BK9Ms$Z<[e,#_A1h($>ojBA3u`FD<Z7B=4D9)PXffC$Oht#b5)X^'[pI0Y?$mAWe4$F:bC_D+]ed$6/I;qWFP=dr=eQ<h-+]LW>K.JS4hcN47Mfj/Phra;L!a"N[$nFVY-97"TE@+=G[f5r=`>h/q3e;trYB9P2Z1jdkKPL=1Ha3`!3\#%*./$o>H2LQ@6:Y$\#j^'iG6B<74bB!&<`O:jZFCU1?&VTgb&Pd.=ATerR;@um;ga`rZTR;Z<0H$8/lju@DHp<HJt=^2:=sS);W4JYEeOK1gSGO,+\5Be]*)#.9r(GU)FLP5$iPT1\+SK)NHAg]%&jX?$1'QC/eg]0cAk0a=iOa18Popb&,7,-+DtJ(P$4*PWqPYre/<Y7OtFPNsP_RoFnl`.tn4se6koi94%F)$JQ0W,VPEF[.'cSh#^m&"mf1j(H'oQd"Tshg'M)7a_jGET;%VEXcgqimiD%Z$Q8uO@)JK=[4o)S!cI/ZD<HCA@Z%ABl!5fN2AmL$btgo7M9S6K90qd]%@hkZnH<cRjer+'(fU;'j2o8UVB1e&dCsgA?G#>:a)fJYqI^PkdbWrr(8Y?E*-Xcs^=\X0X\#iTTi+Te%])*=@q.:N_AGr\TltO=QZ$ceY+?6d=K37:?0hG%I4!sMjK`n?d7Ru7a^jSYT+A1$3TArc^L(oh&J3=I+9+O\hf-j>7irh%*BucIic"12<Cq`k1&3F"Jsu!Yj+A0;1Nqu[WLbYCRD2FS!0ZPkknV5s^a?iF&jjQuP-1E,7`6\f67\0^_n.$\nP<tnEDT=l++JH#:7a^*X"8E&:K11:Q*h8?MPS[a[>6$+eAR1lOpWOR6nt`9R(*_s;RqW';'_Ep\.M/-l%p?V,aFn0&OfTSW>f;g6i2]Z;'_Et\.M/-R>Dk[,aFh.&OfU6`/Q0WPmS0`U.?amE?g@:Z4f0k81HcB,Dr:['/uXV.PZ'5N,D0NE?g@:EYCC+81HE8,Dr9p'/uXV.[[^X`N4YFE?g@:ne3sV81H]@,Dr;F'/uXV.QMJnMPLk`2oOoLR!947CtIohI1i.#k^9ZUe(&[ur,hQnGYVS/6q2HT1=n@BOe2-p1l7?dZIUjd-2l/V5O@P4Z+dY(~>endstream
|
| 94 |
+
endobj
|
| 95 |
+
14 0 obj
|
| 96 |
+
<<
|
| 97 |
+
/Filter [ /ASCII85Decode /FlateDecode ] /Length 986
|
| 98 |
+
>>
|
| 99 |
+
stream
|
| 100 |
+
Gat%!>BA7U&:Mm.fS;RSa&TOYld:Q[@NZJ'[0)Jd'W)9jhcDkVhe.@9iY&k74De]<7qZsf[9<].+#a/&mf4+(c;k3!+FF>l9L3n6H'nOp_[\+Y1JVup`[I^\<j[qC$jCD+JI,bM7UXXOXZS^nS))]nKE7($H*0#pU4\4?pj,9&ogbArXoR40'jXX>m%YIa1_B*5<He%[o,>Ji#U`D&N_(J6*dRACqVh._a4m6\a#?O<R5(LF^Z4qQj)M0[MA[]p@[@Ta?Uhk,12aU^i5O6-X8D5#F7TUi/&s+#hDH9XS8im&\Br^q4*\"RWpk!1k='W:Z!bcB)D8#u<d>1ok0c<TbXbn?B3]]31X/AJ-rV$S`!XJR1"a='!466BU5jVW54N<SOK^9+`.6mMCAfp<*BYi)M_*arE!hqG7G&>c72<sSE=n)q#.A+*XO1P#2);6d7*B`Vi$G^m@ip_/UTJ-oO//XShSW3H7!V(V'?Z=0-flA](:hU3Pj,&KjX!mboV6a\558<f6>tp@ea@\E3q:cT2^YN-Fpjqhh"+AR,T7-0.EtJmRbc.DGAt.RH'q_7G%jg?I(;uq>qWT&RfI$6ZH>6.9VksjM+Vuck*UD]k]M6LdLhKlWH0BYKPJoK%Yuc:h8+="Vd9-?:9'DRlgo4h=!U;1P/_Q(0_N)QhB"[j'],!I#k+M49d6rPA(N(8n)5Zo@d!kjZVNE%X'<<B[i=s#c\c:;k-2ocq5O^^jh#A0[$A'87nE>:4f6geD&d^rk)O74p=.'1m^6fpJ%F?_W3r4_Ds?`fN+l%h8)s*\Dg=OAg%meU`k21kXN_h`[4^mg`<U1k>p3"UZJX;bO7:(654D&H5'6:3fnaO;2S@Fp[">0F1<O]h]bqD[gG\dX@W+_hP.Z3<>/]:q.g\_lm0<mj4?BQ'FHG$7./JLIXPP**7?@c5LXFqAm-qpJ`1t:^n3LZ\5Gj#Cne1mU&U]\*cMsU6@.jgYm0R5~>endstream
|
| 101 |
+
endobj
|
| 102 |
+
xref
|
| 103 |
+
0 15
|
| 104 |
+
0000000000 65535 f
|
| 105 |
+
0000000061 00000 n
|
| 106 |
+
0000000102 00000 n
|
| 107 |
+
0000000209 00000 n
|
| 108 |
+
0000000321 00000 n
|
| 109 |
+
0000000526 00000 n
|
| 110 |
+
0000000731 00000 n
|
| 111 |
+
0000000936 00000 n
|
| 112 |
+
0000001141 00000 n
|
| 113 |
+
0000001210 00000 n
|
| 114 |
+
0000001490 00000 n
|
| 115 |
+
0000001568 00000 n
|
| 116 |
+
0000004746 00000 n
|
| 117 |
+
0000008049 00000 n
|
| 118 |
+
0000011378 00000 n
|
| 119 |
+
trailer
|
| 120 |
+
<<
|
| 121 |
+
/ID
|
| 122 |
+
[<60ac965b2f420aeb084facbec1095250><60ac965b2f420aeb084facbec1095250>]
|
| 123 |
+
% ReportLab generated PDF document -- digest (opensource)
|
| 124 |
+
|
| 125 |
+
/Info 9 0 R
|
| 126 |
+
/Root 8 0 R
|
| 127 |
+
/Size 15
|
| 128 |
+
>>
|
| 129 |
+
startxref
|
| 130 |
+
12455
|
| 131 |
+
%%EOF
|
tests/fixtures/hard/t1_no_gridlines.pdf
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
%PDF-1.4
|
| 2 |
+
%���� ReportLab Generated PDF document (opensource)
|
| 3 |
+
1 0 obj
|
| 4 |
+
<<
|
| 5 |
+
/F1 2 0 R /F2 3 0 R
|
| 6 |
+
>>
|
| 7 |
+
endobj
|
| 8 |
+
2 0 obj
|
| 9 |
+
<<
|
| 10 |
+
/BaseFont /Helvetica /Encoding /WinAnsiEncoding /Name /F1 /Subtype /Type1 /Type /Font
|
| 11 |
+
>>
|
| 12 |
+
endobj
|
| 13 |
+
3 0 obj
|
| 14 |
+
<<
|
| 15 |
+
/BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding /Name /F2 /Subtype /Type1 /Type /Font
|
| 16 |
+
>>
|
| 17 |
+
endobj
|
| 18 |
+
4 0 obj
|
| 19 |
+
<<
|
| 20 |
+
/Contents 8 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 7 0 R /Resources <<
|
| 21 |
+
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
| 22 |
+
>> /Rotate 0 /Trans <<
|
| 23 |
+
|
| 24 |
+
>>
|
| 25 |
+
/Type /Page
|
| 26 |
+
>>
|
| 27 |
+
endobj
|
| 28 |
+
5 0 obj
|
| 29 |
+
<<
|
| 30 |
+
/PageMode /UseNone /Pages 7 0 R /Type /Catalog
|
| 31 |
+
>>
|
| 32 |
+
endobj
|
| 33 |
+
6 0 obj
|
| 34 |
+
<<
|
| 35 |
+
/Author (\(anonymous\)) /CreationDate (D:20260616100330+05'00') /Creator (\(unspecified\)) /Keywords () /ModDate (D:20260616100330+05'00') /Producer (ReportLab PDF Library - \(opensource\))
|
| 36 |
+
/Subject (\(unspecified\)) /Title (\(anonymous\)) /Trapped /False
|
| 37 |
+
>>
|
| 38 |
+
endobj
|
| 39 |
+
7 0 obj
|
| 40 |
+
<<
|
| 41 |
+
/Count 1 /Kids [ 4 0 R ] /Type /Pages
|
| 42 |
+
>>
|
| 43 |
+
endobj
|
| 44 |
+
8 0 obj
|
| 45 |
+
<<
|
| 46 |
+
/Filter [ /ASCII85Decode /FlateDecode ] /Length 1357
|
| 47 |
+
>>
|
| 48 |
+
stream
|
| 49 |
+
GatUs9lJcG&;KZL'm&jf]20@T^R:SO3,Pm[mFhp$ln+]OMsMd6S%/KJ7hVd\>EKP%(bf@]j_u8\k2dG2dg,4-]-i-8&l(%j:dFh8TOJ8N),/5KDatY/8i,Ka=(%"t>&$LlkV97_5CY\f#hT?Xj\HRe!4jYQ&61\HJpAdc4YQ9_\D4a`iTtab0<FoWqp!T%]Di>_J"Z][hS#1P*ZsXqQ&@P;0&6T*HBT(<Z:*#SqE!<in_N$hqf[GrLa[q<;SkLDVs=u8PKDg1?H-M3fh(^LS&:$;j8J-9[+Y2"4^Gn0]Ra425%&HJIrb/S3+0:BIb<JK!lks,:BVIPp,eB'Y5qeqraP-;@e"TIIFs"B$@0;?hJH"rKY6651X>m,2;\8l[ZebfbM-l>iJhA7%G93R=!K3:/GBXM:VD`;&,Ve"m;\5g!F]9!mI<3-bX!9T)D)gCmT$cB\U/f#\pr@L]CkA5P6^Zn*qnDV+V[;H>Nt3oF.^rcH3*/.)d[!_R&CFsAQ2Ag(/tNCOA?Xuko.([#$01\ZlTm:Pu2PKXrE4L>KqCZ/sKbpW[R<IBZH'W9m^CY:G9b8R&lI,8i2?=(h/Sfhbt!.VlTPNE.2+lHHd+f<\^T-Pp9;Z=<Cusc61,?[SXgpj`6/D22HPq@Z\p6ji-TW1T[e+.R8PW,lkVk7^`[dP"%PJ<9HpYaE/VeQ8-O1@6'U,bV=.e0UcF/5sE"$s1+N7eK<mn2P*Sm:.`cY31Il)c4I\KPan6i`Mo<DNcN(T9?h8_'OB1ug^XCFnd\Ks][-=`>We/Q2op+#]u6WSo(7LAnQb%O.8]1eD6P'?N&/PuM\WMA9Ai:d>uVmo\s34OSJg%>K^qfm*_>$q5&E,XR*'f^LT-aq7Qq59a?M6lZVQKZq-d",&HNH=iOYpK_r>dg`opC0:3pN9h:YbE/#TCI7aV[7"-lS2A(P8>!?1$;0+u6@:M5E))+N:?OS8hoUE!:YW3V289cN,sb;.4%%MQN54eUG;5qtF.Y*GFP-A"]I\q2\M?=cBMe&=<5`XM/4h'N>+$8IoD=@X`M4geUj\mnt@V"L[=kJX8+Kq&I@iH%]qj05R0W.,Wi[)\cCa0ej,FI]2O3"ifun#1m%6qCoAaN#u%BS,U[]0h2HCcT4\nbR9(:BOMfd"PJ!JB%B@o.rQ$:5n<CWBF0<"/gb$pd&jb]i^TPCUdQ<?-_`Yd7Gq]Hn&;eiFk2jWB3<5WK]+@^)5*1RM%D:51-a;(hTTgkqZR+S"EfXKfI+NcMKYdpPn6r&'7=IDBa![<=Err7_Beg`[`Oaajh(PMq,,Qj2q9.-CV6!@%kt&aF>1n4gSlbfJJ(Hhr9'bs,VUkJtr>GTPD*5~>endstream
|
| 50 |
+
endobj
|
| 51 |
+
xref
|
| 52 |
+
0 9
|
| 53 |
+
0000000000 65535 f
|
| 54 |
+
0000000061 00000 n
|
| 55 |
+
0000000102 00000 n
|
| 56 |
+
0000000209 00000 n
|
| 57 |
+
0000000321 00000 n
|
| 58 |
+
0000000524 00000 n
|
| 59 |
+
0000000592 00000 n
|
| 60 |
+
0000000872 00000 n
|
| 61 |
+
0000000931 00000 n
|
| 62 |
+
trailer
|
| 63 |
+
<<
|
| 64 |
+
/ID
|
| 65 |
+
[<715a4ec9994a7eb9ee8fef1d4c6cceaf><715a4ec9994a7eb9ee8fef1d4c6cceaf>]
|
| 66 |
+
% ReportLab generated PDF document -- digest (opensource)
|
| 67 |
+
|
| 68 |
+
/Info 6 0 R
|
| 69 |
+
/Root 5 0 R
|
| 70 |
+
/Size 9
|
| 71 |
+
>>
|
| 72 |
+
startxref
|
| 73 |
+
2379
|
| 74 |
+
%%EOF
|
tests/fixtures/hard/t2_scanned.pdf
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
tests/fixtures/hard/t3_renamed_headers.pdf
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
%PDF-1.4
|
| 2 |
+
%���� ReportLab Generated PDF document (opensource)
|
| 3 |
+
1 0 obj
|
| 4 |
+
<<
|
| 5 |
+
/F1 2 0 R /F2 3 0 R
|
| 6 |
+
>>
|
| 7 |
+
endobj
|
| 8 |
+
2 0 obj
|
| 9 |
+
<<
|
| 10 |
+
/BaseFont /Helvetica /Encoding /WinAnsiEncoding /Name /F1 /Subtype /Type1 /Type /Font
|
| 11 |
+
>>
|
| 12 |
+
endobj
|
| 13 |
+
3 0 obj
|
| 14 |
+
<<
|
| 15 |
+
/BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding /Name /F2 /Subtype /Type1 /Type /Font
|
| 16 |
+
>>
|
| 17 |
+
endobj
|
| 18 |
+
4 0 obj
|
| 19 |
+
<<
|
| 20 |
+
/Contents 8 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 7 0 R /Resources <<
|
| 21 |
+
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
| 22 |
+
>> /Rotate 0 /Trans <<
|
| 23 |
+
|
| 24 |
+
>>
|
| 25 |
+
/Type /Page
|
| 26 |
+
>>
|
| 27 |
+
endobj
|
| 28 |
+
5 0 obj
|
| 29 |
+
<<
|
| 30 |
+
/PageMode /UseNone /Pages 7 0 R /Type /Catalog
|
| 31 |
+
>>
|
| 32 |
+
endobj
|
| 33 |
+
6 0 obj
|
| 34 |
+
<<
|
| 35 |
+
/Author (\(anonymous\)) /CreationDate (D:20260616100330+05'00') /Creator (\(unspecified\)) /Keywords () /ModDate (D:20260616100330+05'00') /Producer (ReportLab PDF Library - \(opensource\))
|
| 36 |
+
/Subject (\(unspecified\)) /Title (\(anonymous\)) /Trapped /False
|
| 37 |
+
>>
|
| 38 |
+
endobj
|
| 39 |
+
7 0 obj
|
| 40 |
+
<<
|
| 41 |
+
/Count 1 /Kids [ 4 0 R ] /Type /Pages
|
| 42 |
+
>>
|
| 43 |
+
endobj
|
| 44 |
+
8 0 obj
|
| 45 |
+
<<
|
| 46 |
+
/Filter [ /ASCII85Decode /FlateDecode ] /Length 1666
|
| 47 |
+
>>
|
| 48 |
+
stream
|
| 49 |
+
GatU3>BALX'Ro4Hk^eM*LFB`@nj\/:C^EpWRa&Q$$14UZ5aTj^hqoY_P0M"P4l'Q#=Iehb5'5'tR3D\SRfEEWK$[MP0!6Xt$tWcW'j.,FL*g'lT-!Yr+gEec#c*a%P"Ph)?m`G[n?iK5BFG790+a3gC0X$D>WA`#[[\;@B927-gV+`VeRnP7=s#]DmE9Zsr!d5p;"bBiT0f63:[k?a7B0H_4[=hS@TcPZYU;I.]\'RL_o)JomWaR:V2R.S3BB[`A=]FQBXG:?B;*t4l8\ZjajGnBoAfO`=ld8%:KD.sh4n+2mZ<s$?VDNM""h!;Vu>X%,4pKV$V$LOa$&F%eY*>uhCS>"/A?%H:V3gW+jZY6_tF`9.2`[`0HogtPuYSdG4`Fs4\^:+.)B/488@FZOYVouL1]SVqI<pUW)OO@`jCgA5n55qF`m+WGm57u`LIg5#D3Ljo]7+3FJ"/BX'M'g6X^^Y$8HB;_mA^6ftD8EN_fNm)T0j$LfEl4@V/>V)K1\+VruZX/-VbOZO"9l3r\esf_jO#*1.jN>@*0c*Id*[$>j17=c$J`fMt#,i`*Cl5[9H,&"<--T@Qj4nEE')XPB<tLXRt]9s-ce8>8EtYVA2Kee)L2Fu./+9;/WL.D?-F;:b+Q]tgDjWnp1T$jB5MNMM?E+j$"V/L1_U)?<i=!7X.Semca)BLXs%.%n[n'29e\R^*l,&&2="Ap9/5p0eEJ_1a++#tb*pJS=#tYa[h&hfYk1/MmPHBNh;6[YZ>."\mO,VMC51T;U_kI#L6"f7:NZGm^Pb@r"*U+>gnRp<-?k\OmEaF$:\`!oC^L`WLdh$EL&<V5>)EU,t(=n2[_B:&dKEgT.u&"]&rH+;l0rI,O^"Umg)&o^EcL-(+8/OM2WHb:jZ**46S*gPqt`&)W"qcR5dfpLp)CVLbdK$VTpq/DZR_-$DJXe1&'</B`9q=Y+Y.93KIn-/D\GKEB!R[E,Fn9e0%rh.Hhh3..!I(K^mO)Rplp;1L&J`gF&L/9t-DPbK`2jIOa$'Vb?0K$.H"V$"m@<Waj4[Z);#M\2iB:6PZb%qif14^<e]->9bg/KIP9;ME)ep"ljn3'Y)[eMSpQkt`F*`oHL8gXNU=Qt"e:3:Z2kK?4:jc!+m\EoK137tucK>-r1DNS6b$`'?7UckHRU],`.<4^-&VCIEhTNZAck_(P)I#o=<n2n5c]:-u^HVF>Sn1bk)2:3+q$:3jr9S_/5[S*^k]-5;6Hqhq_qZZ"cpf-rTIoMeGQ4M;:6\m<)Z6IQd/@U`J!7J5lFjUan#4WWY6/5'6__VTRAr*4o%Fpg<m;C(HnX(0+s_%[YZIE@?aA_gnHUJIgk@?S7b0/i?,PCM&)Y=ZkDLWc6IdEXWA3%%EV=&A4[SR,33L4:i<kiQB`S..t%a%o<u;F3#d.8B?1K,4i=WPg];dWfXNl<eC(WOWhshr3J%*gVI_"`L:<q7Ct1_ZKdqYGj2F7FeN3d5naub4juX>>i;&M#&MnT3-1QM8:WIZ+qhmi8gq@?kk8dSJ;O6Jq_o.&%@E3;m!j;U.&'4E3!XaeI((]>$/06[u(rA;?83sYgiekR9s%ElS?)R-C8:]?;8ZpR84HN]Lq>bl>ldBEaPco7EF\OiOQ<N:@`jVf'lqL_L^n40=Uf@@/Z=>;"%bQYu8I~>endstream
|
| 50 |
+
endobj
|
| 51 |
+
xref
|
| 52 |
+
0 9
|
| 53 |
+
0000000000 65535 f
|
| 54 |
+
0000000061 00000 n
|
| 55 |
+
0000000102 00000 n
|
| 56 |
+
0000000209 00000 n
|
| 57 |
+
0000000321 00000 n
|
| 58 |
+
0000000524 00000 n
|
| 59 |
+
0000000592 00000 n
|
| 60 |
+
0000000872 00000 n
|
| 61 |
+
0000000931 00000 n
|
| 62 |
+
trailer
|
| 63 |
+
<<
|
| 64 |
+
/ID
|
| 65 |
+
[<0bc79d36d078dabb3ceb5cbe894ef2f1><0bc79d36d078dabb3ceb5cbe894ef2f1>]
|
| 66 |
+
% ReportLab generated PDF document -- digest (opensource)
|
| 67 |
+
|
| 68 |
+
/Info 6 0 R
|
| 69 |
+
/Root 5 0 R
|
| 70 |
+
/Size 9
|
| 71 |
+
>>
|
| 72 |
+
startxref
|
| 73 |
+
2688
|
| 74 |
+
%%EOF
|
tests/fixtures/hard/t4_signed_amount.pdf
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
%PDF-1.4
|
| 2 |
+
%���� ReportLab Generated PDF document (opensource)
|
| 3 |
+
1 0 obj
|
| 4 |
+
<<
|
| 5 |
+
/F1 2 0 R /F2 3 0 R
|
| 6 |
+
>>
|
| 7 |
+
endobj
|
| 8 |
+
2 0 obj
|
| 9 |
+
<<
|
| 10 |
+
/BaseFont /Helvetica /Encoding /WinAnsiEncoding /Name /F1 /Subtype /Type1 /Type /Font
|
| 11 |
+
>>
|
| 12 |
+
endobj
|
| 13 |
+
3 0 obj
|
| 14 |
+
<<
|
| 15 |
+
/BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding /Name /F2 /Subtype /Type1 /Type /Font
|
| 16 |
+
>>
|
| 17 |
+
endobj
|
| 18 |
+
4 0 obj
|
| 19 |
+
<<
|
| 20 |
+
/Contents 8 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 7 0 R /Resources <<
|
| 21 |
+
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
| 22 |
+
>> /Rotate 0 /Trans <<
|
| 23 |
+
|
| 24 |
+
>>
|
| 25 |
+
/Type /Page
|
| 26 |
+
>>
|
| 27 |
+
endobj
|
| 28 |
+
5 0 obj
|
| 29 |
+
<<
|
| 30 |
+
/PageMode /UseNone /Pages 7 0 R /Type /Catalog
|
| 31 |
+
>>
|
| 32 |
+
endobj
|
| 33 |
+
6 0 obj
|
| 34 |
+
<<
|
| 35 |
+
/Author (\(anonymous\)) /CreationDate (D:20260616100330+05'00') /Creator (\(unspecified\)) /Keywords () /ModDate (D:20260616100330+05'00') /Producer (ReportLab PDF Library - \(opensource\))
|
| 36 |
+
/Subject (\(unspecified\)) /Title (\(anonymous\)) /Trapped /False
|
| 37 |
+
>>
|
| 38 |
+
endobj
|
| 39 |
+
7 0 obj
|
| 40 |
+
<<
|
| 41 |
+
/Count 1 /Kids [ 4 0 R ] /Type /Pages
|
| 42 |
+
>>
|
| 43 |
+
endobj
|
| 44 |
+
8 0 obj
|
| 45 |
+
<<
|
| 46 |
+
/Filter [ /ASCII85Decode /FlateDecode ] /Length 1544
|
| 47 |
+
>>
|
| 48 |
+
stream
|
| 49 |
+
GatU4gN)%,&:N/3m"6A7N!s1s&/W)Y(FNs/8Wi54m[M6t1/;[H[FOO1G<pfleaPK]E,5p9"7/&'$NMDjH8,AIrsUU,i8P(E"_:s?'iU2G@$V9$)IPh1KP?op"\)^^a@+E<1@s<!qK5%R?sJqn5LY2cd/!p"nB7L@bXlNjSiskl[X1*Ophp*ZC_(`Fh=GN6p][sAOM-;<;^h2285C7m6q8m&!2V`uMLmCYkILGZlreXas75p`E!_=KKTK*K3FoI5#3f[(\liVkfbSa/r'0B6iT9<4pNg!;^"L[hYg.,pO\L0^(]5YOa)Jh?p\O[UE![L;\@`AX,]Iu/)+QhlnEshdB<h<IgHY,@;MI<imI,7c0LS+aS28I&h.ij;dd?,[jmCR"(aYK\g)aR;I5[g#N06_`FOdk::gg"<0HlBI&=>9u;9fj2H[aOtd"ZNqSPpQBmbl8M>15]i(/Nd,\J)6*5RkS40C`jNiN/-E?kUSkYXdX>mJceKqr.'V?]R]eQrPJ.8=t<remjD-QoU<8n$@VJM[JE#cgd6.5:0MBo`1W05L$+4jV0nT>=)Z4Ap1#Yi_;HnZW,<5m%GD5U2(_+![Vn%W!LEV&!K`d'G@-.+XH;",f%n''ZfBPoDTN&dp.S_W^2>W`*C6[k9RrAiX=3\K;,7uieMD^V5&][;^#,66MFCji:CM<g\@AJ:?T.p74kkk(1HJ>8*CYN%Ib3m#fdODP1cX*"kSG8gJ:7gf-M/PM&eq"Cl3s$R^oSs.cI<q&ihab6I6LuKY3Y5&jBGMAm=1Cd9_Edf!T-i3U`Ji@=8Bim2AUp&?/fQ_9fQ'0LZ^bh)80++i).aoG5mBI,ZGSgQWdW6C=&T"k@?h;3^jL*?"7>etsQ=$4''b"\D'q;^$>F;E:"1ctS=#L/&<?:r4Xka:h$)9oB?]<"Q'Y.K]P>C:3iLg12;6[-c4aUdJnR'oap#8&J2UaMsq[d0OuSL>\5O<ZP9V&GBnH-%)17dLBd;jE_#oeRkAMPnm*-ekf";EAA8`E_ORb'`h#Y3-i!cU;DW$mal(RqM-u0NG?V7&.)uZ+Ab&;43Ab_LMGn'@\t^e2O(W6V')G[lh$7g'!?`-er+W*b<%8G[9J8kbKABGRH]Mn!0[CdQLVBpkYTh.2Mlrjd_;rQrsmeQEPBbmCFn_?]Irqnhit_/^5gaIS7/SfmAj(-Xr$na^)>CZ,2:?5jECYe\rf&Ad/J(56,#afO[dL,[Pojs3YD=6[=CFen$$5]MPm7P\KaHX>\lN0KL8Wc>s\FH!pA8M^EDEP1FU&>F`t*Fld^6oQkac0FF2<"<D-?EF9%.H?SX;&/Z6hkXlNR[q)4nTJr\dhrO[>>T^2VBnIG+0D]sT%"P__K/)Tr^Dg;M17+ACab_a:Y<t_m*<@fta(4$u9$]Um7qJF2<UXFasWl'ml`E!lO;7,O:"$_YY<Ft)Yd3>S!r$YbD#m5Wei2Qq8dD)Zl2b3=.eTl3-/ZZ$c`UerHd]YEO3dT]Bj"W1c^G!<3]WqB>M((CZ@3_Zj5*si]q#c8!!>%]QFo~>endstream
|
| 50 |
+
endobj
|
| 51 |
+
xref
|
| 52 |
+
0 9
|
| 53 |
+
0000000000 65535 f
|
| 54 |
+
0000000061 00000 n
|
| 55 |
+
0000000102 00000 n
|
| 56 |
+
0000000209 00000 n
|
| 57 |
+
0000000321 00000 n
|
| 58 |
+
0000000524 00000 n
|
| 59 |
+
0000000592 00000 n
|
| 60 |
+
0000000872 00000 n
|
| 61 |
+
0000000931 00000 n
|
| 62 |
+
trailer
|
| 63 |
+
<<
|
| 64 |
+
/ID
|
| 65 |
+
[<69e5a7600007f93a517cd7426a607496><69e5a7600007f93a517cd7426a607496>]
|
| 66 |
+
% ReportLab generated PDF document -- digest (opensource)
|
| 67 |
+
|
| 68 |
+
/Info 6 0 R
|
| 69 |
+
/Root 5 0 R
|
| 70 |
+
/Size 9
|
| 71 |
+
>>
|
| 72 |
+
startxref
|
| 73 |
+
2566
|
| 74 |
+
%%EOF
|
tests/fixtures/hard/t5_european_format.pdf
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
%PDF-1.4
|
| 2 |
+
%���� ReportLab Generated PDF document (opensource)
|
| 3 |
+
1 0 obj
|
| 4 |
+
<<
|
| 5 |
+
/F1 2 0 R /F2 3 0 R
|
| 6 |
+
>>
|
| 7 |
+
endobj
|
| 8 |
+
2 0 obj
|
| 9 |
+
<<
|
| 10 |
+
/BaseFont /Helvetica /Encoding /WinAnsiEncoding /Name /F1 /Subtype /Type1 /Type /Font
|
| 11 |
+
>>
|
| 12 |
+
endobj
|
| 13 |
+
3 0 obj
|
| 14 |
+
<<
|
| 15 |
+
/BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding /Name /F2 /Subtype /Type1 /Type /Font
|
| 16 |
+
>>
|
| 17 |
+
endobj
|
| 18 |
+
4 0 obj
|
| 19 |
+
<<
|
| 20 |
+
/Contents 8 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 7 0 R /Resources <<
|
| 21 |
+
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
| 22 |
+
>> /Rotate 0 /Trans <<
|
| 23 |
+
|
| 24 |
+
>>
|
| 25 |
+
/Type /Page
|
| 26 |
+
>>
|
| 27 |
+
endobj
|
| 28 |
+
5 0 obj
|
| 29 |
+
<<
|
| 30 |
+
/PageMode /UseNone /Pages 7 0 R /Type /Catalog
|
| 31 |
+
>>
|
| 32 |
+
endobj
|
| 33 |
+
6 0 obj
|
| 34 |
+
<<
|
| 35 |
+
/Author (\(anonymous\)) /CreationDate (D:20260616100330+05'00') /Creator (\(unspecified\)) /Keywords () /ModDate (D:20260616100330+05'00') /Producer (ReportLab PDF Library - \(opensource\))
|
| 36 |
+
/Subject (\(unspecified\)) /Title (\(anonymous\)) /Trapped /False
|
| 37 |
+
>>
|
| 38 |
+
endobj
|
| 39 |
+
7 0 obj
|
| 40 |
+
<<
|
| 41 |
+
/Count 1 /Kids [ 4 0 R ] /Type /Pages
|
| 42 |
+
>>
|
| 43 |
+
endobj
|
| 44 |
+
8 0 obj
|
| 45 |
+
<<
|
| 46 |
+
/Filter [ /ASCII85Decode /FlateDecode ] /Length 1611
|
| 47 |
+
>>
|
| 48 |
+
stream
|
| 49 |
+
GatUsh,E>l&A[2p=6Pe'pr)UcS$rq1V<."U1Xk[^KgP3^.jS1KSc8MOCtTSZAUCSD!A*s9(ThjlADf[SrP!bnm'pS]VoXs%q2(g+P*Hi"=jI#<@FUO!CTB]UUFfP68T3e2:l/OCcb'L!SU0m?muSC?i/f@s6VZ>B@D0=$?jGfUgR]GCD?")2rS;qo]\2_&@c\D,^16V=X84qBpeP6L9"2A'.nD[h/ChjgHDp&.A.RhspUbp`q]giO6mRFnmod2+QYaB<>i/kQ9sOlS$2T.fg!@:.LX"">*VX`j>YPJrn:O$]D.#;lQcIq"&Hb,uEC`?Pm<;72WsMqEG`hW2EW($dIGB;FQ18t*^#'g756q7?"$cl@*a(T'K96sG]5MU3.rLk+?imXBrO%GZF%aq:iR71J,GEPGE?ZVB1+itaksDQ-OJ;CPJ6\<3a2,'J%K#eCq[B)8-ff3WoR10TULn%QTKG4(S!kUpkU=8uCo=f=G5q..FoSu+p?SIWmPH3oIm3;t<JceVD-GK/bN9tKCOced>2AuH-d$k,kQ*P?qS"<6Q=US=<52V&V\eO2Z1[K=>:iVkU!)A/bu)[80N8-oK<c&U+d'R:BJ\sO:m<G//e'2/$=='s<PMV=/u^1_dn$6[H+NR"=![t9j$98+!IBDW)Fmuq$>i:HCPr5I`+A/P\c<_E-!A^^Bs^`cIQ@H,qMJSX>2EN.(X#&oUllt0d5[pK3'(%f1I>;M%+$=ROUDJLPXDgNCK`NR2QC-APdbFOH$t'h;5'./RA*)^\7k5TdK\<[j7beG$Cgo6)U-.:DlQ0S.L%TSeqY42,g8`kVhD1)N,;9-9lm+ik?-V0L!J1=6^h3R!%82PL.G$T95YY^<'S*bIq80Dq'%Lb'Y2(<j<l+YB%;V:'lKfn7K!6ZPI;`%/7>Dm;:T:tqsFSMPUbFNXFs9"M>O8mm0+4pdJ9rB24?jD3a>=HkSp\G((,de7=CbiVNF@@o;coB$FdI5q>!V?e7*Mq7FI-J.7fEaW>K><WC,^X)\ut=8?kA,3,P<Hl90LZN6&AeVXZRaoe@.M(Fh4q>?]6nY_EqBNb&*YeolSpeYa>9,hT'oIG8necnMEaRYRL>+:=MkBK0;#I7;AuU#=(c;h%*W;qh(Ij0\%`[GTK:T\Q5U0J+Q;TM:!JXr9%^83aEEV+XE`pfhVW;o`c4Xli1@q3euT?S,>l+5'5>8\W^c[3D1-7+Ghql,?6h_;[pRW(sNbrjH(Icm;N>B:3-?@an^h?S'e;Usafi3=S?'1=o;O5+18COJ'CJZ\C+f:u#5]'8)Y,qXi4CJO9=e+RNG%2BSD>F\O'e#W#AU1t$IYGD_$H64Vf\lD%#$-Ah+SMc/[%Wnt[!qhW]V\]^*'O<[tC'>"$'pbc?%bPfE[I$nsXkd5i!rU0.>CrRdu[sb0&DVo3HlA;Vn#pfP+=9!tBYLD_0inl"[M<R-JSlF]NXi/Q,7,h:M`6XW8i`D*B?-3a[Xi.(SKN0PMBa/"r8n=AV=LAc61C1$^!j=A0/d%Fei4A\1%<&iA.LiPlfHLbVP/sg.2fY[n8iFVJO57OCL2'*Z@Ir8Ga\OWgam,cl,'?n<Pq5*XdLXl`c2If[Qrgp~>endstream
|
| 50 |
+
endobj
|
| 51 |
+
xref
|
| 52 |
+
0 9
|
| 53 |
+
0000000000 65535 f
|
| 54 |
+
0000000061 00000 n
|
| 55 |
+
0000000102 00000 n
|
| 56 |
+
0000000209 00000 n
|
| 57 |
+
0000000321 00000 n
|
| 58 |
+
0000000524 00000 n
|
| 59 |
+
0000000592 00000 n
|
| 60 |
+
0000000872 00000 n
|
| 61 |
+
0000000931 00000 n
|
| 62 |
+
trailer
|
| 63 |
+
<<
|
| 64 |
+
/ID
|
| 65 |
+
[<6ec1ea032befc17f2cf599bacd3930df><6ec1ea032befc17f2cf599bacd3930df>]
|
| 66 |
+
% ReportLab generated PDF document -- digest (opensource)
|
| 67 |
+
|
| 68 |
+
/Info 6 0 R
|
| 69 |
+
/Root 5 0 R
|
| 70 |
+
/Size 9
|
| 71 |
+
>>
|
| 72 |
+
startxref
|
| 73 |
+
2633
|
| 74 |
+
%%EOF
|
tests/fixtures/hard/t6_multi_account.pdf
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
%PDF-1.4
|
| 2 |
+
%���� ReportLab Generated PDF document (opensource)
|
| 3 |
+
1 0 obj
|
| 4 |
+
<<
|
| 5 |
+
/F1 2 0 R /F2 3 0 R
|
| 6 |
+
>>
|
| 7 |
+
endobj
|
| 8 |
+
2 0 obj
|
| 9 |
+
<<
|
| 10 |
+
/BaseFont /Helvetica /Encoding /WinAnsiEncoding /Name /F1 /Subtype /Type1 /Type /Font
|
| 11 |
+
>>
|
| 12 |
+
endobj
|
| 13 |
+
3 0 obj
|
| 14 |
+
<<
|
| 15 |
+
/BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding /Name /F2 /Subtype /Type1 /Type /Font
|
| 16 |
+
>>
|
| 17 |
+
endobj
|
| 18 |
+
4 0 obj
|
| 19 |
+
<<
|
| 20 |
+
/Contents 8 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 7 0 R /Resources <<
|
| 21 |
+
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
| 22 |
+
>> /Rotate 0 /Trans <<
|
| 23 |
+
|
| 24 |
+
>>
|
| 25 |
+
/Type /Page
|
| 26 |
+
>>
|
| 27 |
+
endobj
|
| 28 |
+
5 0 obj
|
| 29 |
+
<<
|
| 30 |
+
/PageMode /UseNone /Pages 7 0 R /Type /Catalog
|
| 31 |
+
>>
|
| 32 |
+
endobj
|
| 33 |
+
6 0 obj
|
| 34 |
+
<<
|
| 35 |
+
/Author (\(anonymous\)) /CreationDate (D:20260616100330+05'00') /Creator (\(unspecified\)) /Keywords () /ModDate (D:20260616100330+05'00') /Producer (ReportLab PDF Library - \(opensource\))
|
| 36 |
+
/Subject (\(unspecified\)) /Title (\(anonymous\)) /Trapped /False
|
| 37 |
+
>>
|
| 38 |
+
endobj
|
| 39 |
+
7 0 obj
|
| 40 |
+
<<
|
| 41 |
+
/Count 1 /Kids [ 4 0 R ] /Type /Pages
|
| 42 |
+
>>
|
| 43 |
+
endobj
|
| 44 |
+
8 0 obj
|
| 45 |
+
<<
|
| 46 |
+
/Filter [ /ASCII85Decode /FlateDecode ] /Length 2156
|
| 47 |
+
>>
|
| 48 |
+
stream
|
| 49 |
+
Gb"/)961e+&AJ$CkhlP7%772+\BV2.A<XFQljaSI,K?ENn3Xpir;H7M1_4s+n-5n5TNo9mqifOeWk6gX"s=>(T_\KUL6,P,St#i_:9$Ma81I3=,)baMY9W7m;Gh9GPJh--Pu&+6Tp[e#cgQm5#3[GRiAW_>-Xc6H3No=_c)DAnU:kkG*`Z9KDTV7imSC.%I<0aXF1&XYJQ2)0ocn`EKq][8nRnII9)$'d#=t8)QFG-`He,E>S:+@C?@Mg!GPdVhAbd/C]PeBp"@$k-or"kHp$NiL)^!r'0!Ck0@pfIMN&$^(/8#2?49+g?bB-lg)!e,b)68AEqosa-e1bNlj>u4"m=uS7^bnTqC;Pt4]mY!S5r%q?KMd=VD?o+MQcm_,T5Lhsme5\7N`a:k"+J#!h%/TI</Y2Qq<+ff6*-7!(tiE2YAlcDH"kjF(k9E@'@++FZg6Q(E`I!`PB),\+S59gJnu4#;X;-VSn^>J6ElsYQkr@TWb$dWLP*X2q'O0^ns68rA(ncF(Y.bbq((.4R+3Ii;E@+L)i,IBZS]$_:Uu0L8G`-e@22Vt@N;pp3N>3%*W4H6]m@*C<'P4SqLra13g\REG&W+;2u><Dg.:L6@P`?j'FN;'B8@+1\g=BJLj&:LH*u9U[M?>$QR=Xbi%DF.mEc-`DJn6JaW*13rUr'2*(8b=VB@'>_6uqZC_f_UIkb/D8@;SYC$C<2EJiSV0L\4pYpd+JQVEuZ%gQ$Pi)Hot7FMtoQUe<5mg3,L9Ydp)W(GJ\5<FTNB7b/n+PO_M+[Js?$X$;C*WtI8mGO2A'YmXoWC#^b;cLK$@$b=9_:jOX'uFo>UVSG6"GXPjlerM/7Oq"@m"Sq".d8>h*>+mG#ZW<LdE`&tY[#-4.mTT:+S.")n<;tWjiQN[KY/kFLcfP&&su3DKtac.$q?Rm$DBfJRDMdG!3i.p&Z2^*@E>LQ*I<-2^"ALSok_0B1`ZTiEHI&[j*]foRVETK<f][qFdpE\oPC#,OB5LFFEppd[QZ'O^Jl#MEo&M21)7"$Y:($Q;]#3m4C9$W:`W%f1cu2%@':L2W8$+h/;n"nhG[L@&;d-V'/`;W.T1ctgmIk#p1Ok5bOMrrE0P51o":FFTrZWOniEr[di@g4:#3'0OC"NRm$el&"eOH<I?]Sg,DTSq\@RR:HCZs2H"Ps66QDq4WF!U]*!'"Bp3^Q('5af<)1jb+>q?q!JpBf8L2p-NSN-XtJ\\V9;AM=KI4?R!d5U1Sh/pfB^3T^:J)2:+]&5E,]Dlc?*DoE-3SNGf*;cQO,+-4<=>s"taL8@XdI5cL[qp^:&Gp,!An!L49H:NQAAP2#K"4+Oi)=R1@q9,d-Z9#Ek#"A9(-D5_Id/M2lp:U">NFFhqPgsnYm9r#Dda:if\h<"45bkdc$4LiXKKR$X'oj"?<W:gSh06[Q)4l@HL:.\YV3DQ<!QuOoUc@=B<$g*g?Ln`XO9!mfN>c3hYK>[N_&)J=UL5Us!g[dB!]O*d\@#:&N6cCI03VER#q==E*:?CjT]AH3NKHq/7mS,&`70!rldGpQC;--%86>*]m_46_'"#i#g6:nodF*ZB;s?e?]LSY=,SY='!"]Ij?]MW$-Pt?rMdMBB^D0nU<+IU;1^<5UD+5;UF)H93n,c'TL3.M#i2-AWDKb27!o(KC0t`\U"DFpL=/\L]:4.&'](,#/j$M(#KfkimLki=5_)"&N8fQ,UG"')oO=%9,keVS1;kT^]/+a-SfQ?TVN`"JCY_[bE8jTgW>mW"^DNIPU-oM*ULk>(CJo&:o;;;%)j#11Lf&<8D6^eZV9u8!Pp8f(6>8_S1h[D`U\;CS_!"L,2-r`2N_r@;bh&Xm_2Y*M\s8%b;Kpu5#]11hBTS9/mo3J8bEeo>iOFkSo=Z'-GE?q14^"a@,XOCjOM[g4Lj"=Rj.7O8$7AsR2"?`.hI4j+n%I)09I)7d?:)Mm4KRU.</tFA[KaL,ZlB83+`c).(XPo)\E+&p>U3ZHl(QTD*:75(0KM&rnm5aHkjfV"oB2%VZe+96ffI)MXc?JBG[]p<ON>4B%E]Ac=K8-eS:69YfV'[JKsmuPYSW8&&&hBA9(J1c9,aQPVQ\[.8;Q6M`gB`b`FEsg=Knu([&MWCcLqVA?%)RP55igXVsaRtM,j^%8,iXnQe>%~>endstream
|
| 50 |
+
endobj
|
| 51 |
+
xref
|
| 52 |
+
0 9
|
| 53 |
+
0000000000 65535 f
|
| 54 |
+
0000000061 00000 n
|
| 55 |
+
0000000102 00000 n
|
| 56 |
+
0000000209 00000 n
|
| 57 |
+
0000000321 00000 n
|
| 58 |
+
0000000524 00000 n
|
| 59 |
+
0000000592 00000 n
|
| 60 |
+
0000000872 00000 n
|
| 61 |
+
0000000931 00000 n
|
| 62 |
+
trailer
|
| 63 |
+
<<
|
| 64 |
+
/ID
|
| 65 |
+
[<37bdcc57daaebadb31f3c69feda0eaf3><37bdcc57daaebadb31f3c69feda0eaf3>]
|
| 66 |
+
% ReportLab generated PDF document -- digest (opensource)
|
| 67 |
+
|
| 68 |
+
/Info 6 0 R
|
| 69 |
+
/Root 5 0 R
|
| 70 |
+
/Size 9
|
| 71 |
+
>>
|
| 72 |
+
startxref
|
| 73 |
+
3178
|
| 74 |
+
%%EOF
|
tests/fixtures/hard/t7_diff_balance_labels.pdf
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
%PDF-1.4
|
| 2 |
+
%���� ReportLab Generated PDF document (opensource)
|
| 3 |
+
1 0 obj
|
| 4 |
+
<<
|
| 5 |
+
/F1 2 0 R /F2 3 0 R
|
| 6 |
+
>>
|
| 7 |
+
endobj
|
| 8 |
+
2 0 obj
|
| 9 |
+
<<
|
| 10 |
+
/BaseFont /Helvetica /Encoding /WinAnsiEncoding /Name /F1 /Subtype /Type1 /Type /Font
|
| 11 |
+
>>
|
| 12 |
+
endobj
|
| 13 |
+
3 0 obj
|
| 14 |
+
<<
|
| 15 |
+
/BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding /Name /F2 /Subtype /Type1 /Type /Font
|
| 16 |
+
>>
|
| 17 |
+
endobj
|
| 18 |
+
4 0 obj
|
| 19 |
+
<<
|
| 20 |
+
/Contents 8 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 7 0 R /Resources <<
|
| 21 |
+
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
| 22 |
+
>> /Rotate 0 /Trans <<
|
| 23 |
+
|
| 24 |
+
>>
|
| 25 |
+
/Type /Page
|
| 26 |
+
>>
|
| 27 |
+
endobj
|
| 28 |
+
5 0 obj
|
| 29 |
+
<<
|
| 30 |
+
/PageMode /UseNone /Pages 7 0 R /Type /Catalog
|
| 31 |
+
>>
|
| 32 |
+
endobj
|
| 33 |
+
6 0 obj
|
| 34 |
+
<<
|
| 35 |
+
/Author (\(anonymous\)) /CreationDate (D:20260616100330+05'00') /Creator (\(unspecified\)) /Keywords () /ModDate (D:20260616100330+05'00') /Producer (ReportLab PDF Library - \(opensource\))
|
| 36 |
+
/Subject (\(unspecified\)) /Title (\(anonymous\)) /Trapped /False
|
| 37 |
+
>>
|
| 38 |
+
endobj
|
| 39 |
+
7 0 obj
|
| 40 |
+
<<
|
| 41 |
+
/Count 1 /Kids [ 4 0 R ] /Type /Pages
|
| 42 |
+
>>
|
| 43 |
+
endobj
|
| 44 |
+
8 0 obj
|
| 45 |
+
<<
|
| 46 |
+
/Filter [ /ASCII85Decode /FlateDecode ] /Length 1614
|
| 47 |
+
>>
|
| 48 |
+
stream
|
| 49 |
+
GatU49iDuk&A@ZcFRXiU%7718j^oI<b8(-!*Y(39i+0q2hK>@2hbS#Wd`BOD4LHV>&rV5(mRO`m9*)p!"3o^608.PaEde;t32o+TOAqD>ROm`fs%X[ZV4uZn-Ag-9.\S-U6SiN%]2'g<!KpC:rdkV?!UCbHmbNHVe-TR2?9aV%HLk]DB(rM/OS/U%^NnMFmXHJSB2ZJfB89=-j#qkfE`"mZdLVH1BX59F(>6$KI?X$^o\j6Uol^;#!!)1e#XQa,]i`ls3hP<dh:+14Ja<O>[G^,,mIT@MpU6'.[pH/WZMm+ll\c@k3W#Gb;`Vp>Z[:Ci^*O35R5jAg/$e%FO]E^4@)nb55E"4noDS!mL<OhqYFkgNPKW@]g\hVGR/j9)A!F7YiO..L(k-qKgHBBpZg_V?Efs?',Jr47p&lhC@EoMEnm;n2%SAfL3B'h:Tu(apS^7)]-YdW'1Xmn6-cD2(L4"B$55hB*j]Rt2"=BEheV"n-\Xm,8IC%ujN"V@pgAs6?*d1?Zm[pqkrHPPuM@BL9e%QpF\YR#?K$JS0a!0*mDbYH.:l5\:=%K-b=bFcWr4,%=8-\j*3W24?l`3$K/M8'qY!CO\*ogth"=g;8@2n8QI#AgZiB71rXEBm&`*upOjc7Pdr@YL%g+noQ(^Bb]H9GOWa8*=NRGY05Su>MlYSit+>,9t;R=h3]FP0?`;-:'>mig,<?roqHfY^'5\SPu[EARdqDMJ!iYHAdmdXjTuYMIdM%s!3([)/+kd$k,lZE85#ZcY:WKHeks76QZ?JG3)BfKp+GQ4:GR]J@K8m>ePi$Ql<YoOfZa-ieF@.+n5)1HUJ-K_H'I9dmDc+<#GK;<8L7G*lrnQ\d&=Wm\WGfaq!oeAmOn)W:;M$G#!BKP?HFc1A;9nDVq-S'.oQ]h_%-0ORbR,0pO=PqYH(L3T`l+9NS'1Ve?^!nZF&4i:m-i=r1dOQKM<<Dop>1\t#u6C'Yr`)F%`)>\544ZihZYAps[V4eM0.Z.EZC2J&WnUn2nJ0W;E`jWjUL):gX!:<nn5)s6*pQa3bOq'pH:_K)H[ZH0bpYiBFP2NcXph"M5K'V-S%--a[!I]2M?r4ol[VONLh5ZF,\hJVC\%3gD'>;L[ik7ScSOqQW?Pk^T"8[1DhlY#4gY(-MU:pEE_M!j?ZUNDYS('pT1&1/cBl@AqZ*LYp6L7O,l6&(H7V+jbaNg6KfGI3M_!!1(b$W#?9;dj79_=%8_hY<7)B1H1bdd0[cj+tJKeA@Cd$HND@tZY&+.8"l,Xm8hoHl&b'bs(Sb(I-])i4TDF]0qork5q6?<oB(*3K[$fA4QDCj9)aV2JNgOr"C=8c>\AHIEMqWC-=3"3SXn6Mp?p'oY=sU$2^!3d`=co.'$^VF%6F.m75MM>>3VTD7U@#mLU3nb)^q^T66fW$L9K;tMA!7fDV?X;_PjIa/FPatd4WkkDs=J]2`&30*LPJu`Fu6_%:uPq4mB1(17Z"^K?jYEHle)>I4;kWAs^8f!h-i)e%;o*kOH=kH"EXNSE@$+MV%ahsT#]#oZ+0"/hgM_+Ebi##OaVH\K8n?o<Ts'IeDQ/3@mG?3!g1F9[kIa>+D2GMn-_tRhmhgEMeGMMdaCB~>endstream
|
| 50 |
+
endobj
|
| 51 |
+
xref
|
| 52 |
+
0 9
|
| 53 |
+
0000000000 65535 f
|
| 54 |
+
0000000061 00000 n
|
| 55 |
+
0000000102 00000 n
|
| 56 |
+
0000000209 00000 n
|
| 57 |
+
0000000321 00000 n
|
| 58 |
+
0000000524 00000 n
|
| 59 |
+
0000000592 00000 n
|
| 60 |
+
0000000872 00000 n
|
| 61 |
+
0000000931 00000 n
|
| 62 |
+
trailer
|
| 63 |
+
<<
|
| 64 |
+
/ID
|
| 65 |
+
[<235cdedb5bff95f492d3fc2f0ee7d36d><235cdedb5bff95f492d3fc2f0ee7d36d>]
|
| 66 |
+
% ReportLab generated PDF document -- digest (opensource)
|
| 67 |
+
|
| 68 |
+
/Info 6 0 R
|
| 69 |
+
/Root 5 0 R
|
| 70 |
+
/Size 9
|
| 71 |
+
>>
|
| 72 |
+
startxref
|
| 73 |
+
2636
|
| 74 |
+
%%EOF
|
tests/fixtures/hard/t8_text_dates.pdf
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
%PDF-1.4
|
| 2 |
+
%���� ReportLab Generated PDF document (opensource)
|
| 3 |
+
1 0 obj
|
| 4 |
+
<<
|
| 5 |
+
/F1 2 0 R /F2 3 0 R
|
| 6 |
+
>>
|
| 7 |
+
endobj
|
| 8 |
+
2 0 obj
|
| 9 |
+
<<
|
| 10 |
+
/BaseFont /Helvetica /Encoding /WinAnsiEncoding /Name /F1 /Subtype /Type1 /Type /Font
|
| 11 |
+
>>
|
| 12 |
+
endobj
|
| 13 |
+
3 0 obj
|
| 14 |
+
<<
|
| 15 |
+
/BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding /Name /F2 /Subtype /Type1 /Type /Font
|
| 16 |
+
>>
|
| 17 |
+
endobj
|
| 18 |
+
4 0 obj
|
| 19 |
+
<<
|
| 20 |
+
/Contents 8 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 7 0 R /Resources <<
|
| 21 |
+
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
| 22 |
+
>> /Rotate 0 /Trans <<
|
| 23 |
+
|
| 24 |
+
>>
|
| 25 |
+
/Type /Page
|
| 26 |
+
>>
|
| 27 |
+
endobj
|
| 28 |
+
5 0 obj
|
| 29 |
+
<<
|
| 30 |
+
/PageMode /UseNone /Pages 7 0 R /Type /Catalog
|
| 31 |
+
>>
|
| 32 |
+
endobj
|
| 33 |
+
6 0 obj
|
| 34 |
+
<<
|
| 35 |
+
/Author (\(anonymous\)) /CreationDate (D:20260616100330+05'00') /Creator (\(unspecified\)) /Keywords () /ModDate (D:20260616100330+05'00') /Producer (ReportLab PDF Library - \(opensource\))
|
| 36 |
+
/Subject (\(unspecified\)) /Title (\(anonymous\)) /Trapped /False
|
| 37 |
+
>>
|
| 38 |
+
endobj
|
| 39 |
+
7 0 obj
|
| 40 |
+
<<
|
| 41 |
+
/Count 1 /Kids [ 4 0 R ] /Type /Pages
|
| 42 |
+
>>
|
| 43 |
+
endobj
|
| 44 |
+
8 0 obj
|
| 45 |
+
<<
|
| 46 |
+
/Filter [ /ASCII85Decode /FlateDecode ] /Length 1634
|
| 47 |
+
>>
|
| 48 |
+
stream
|
| 49 |
+
GatU3h,E>l&:`#5TnbbW)^D`d(2k?!N]&/n`6=L\;97R4L#a0#l+c,@/_LVH3M,PI!`o$(+$XOb[0"57"3'0<=$eN[EcqTh36AoHOAqBj8gaAlmf<Qe<)d)@,Ve"N-'Ue\'XL-=q`6tM:`2^p=#@u)=Ds:BD[nV%:'cdKr/$s)laK$fGhDp_<r7[NqK9L)s$H9p,l^,)&"si\-iC?jU3%,C4$^r`_=B?`ABtVV7Be/7a2dR3S,G>J!7&+dJt/!OZHC:&4J(HTnec15FFsNWkN([sj_;Ci0E'b0-;1Ycn&[E$m,=J@T6+CV#?e-PceG@=_VgB01X<KSq^iE1W^dciGd=\H1&nuE4E%rCL:fWb*nTRc#V9JE;9"X.eP?.?g[WJ\mEhu\$o_//*aW]12M/k0ON%plRW!f[#GiH7S/\GX:gpksc3LrNnQAfk7^7VAn[i"h0-AQ:s--K1m%t75j(e)4KVLnFG]JYQfnd=m;20+(CZ,'VEVKBZs1I0pj1up</fP.I54@7"S80UbX.\M/qC.7YQ<IL&_lI^+gGTTLC0'71OP>O2H'aUE49L%SNA#'*=^L!Lb=f.2>Ue#N&K1c:1lnXg!-#<R4>b&chb)P<0N]P.T?egTN0io]aAMMk,O``/i((8O5NXbBqcge%NlDaYdQ3\M%#h&eb=[%nR%tW51bd`gmAY_N[-$]*#V('8:G,;3U*n9;s#coL5L5\l`t_X+OIi@L>,QU#C3f2_-o/anQJW:O-+jDS)0N)?V_a#VfM4JP10sI84a@80h$*NOI!N%HG#C[Wa'ucKC2NbrRXgSFcOBf;ZkjQK[*,tr(Y4kV@*:$3WZT-7c``YUD3bj43O\`m-i$'[D9:G/4Oi#%\Ei"l!)Bi$N7gl4I#kRkV$Xh*pLG02/Tn%37d84MW/R2l/^?/mGa_U<$9Y(DSUiUsW%OYkoH2PGB>EEKo#];j/W8"Nfjc=$23a3/5rG4>d5J]CXarguX[SNfD)7);#bJ(;/4Jbd[O4_1lon[CeFl;%,1TF4cX;/,))+QTW]^NW&a_=Y=I[[V'12G54a@80%k"6)<)clC##uoln;u+u"qu%n;@P.\N92\b=<)-o#>8-a1%sR4,QmrLNjt4.W??3iRWsd)LD/O,"4MC:T4go1<a(CRQ6p:gqLtM^:Opb<q:5-"GI"flnFSuO,KHU^\rK[?)UhF,FuPuG(jD.#XtrtBKaU%6ScBkYViqj(Gkr4HUp(0-=GHZC\'\3^;&1g[W<'87(W!V,R-_JDSLhr!H4Dt:rbh>7(.[T1A<ZNcCToiVGV_(R]Ig*OW6-rs\utbDb8Q)KZ].^#M0SrqZE5%dO3BO_p[Jr#W[3Gj-@ef;1kN>_0=7%:ZV@sU/4TAE[(7+kAHsj@3Qmg;:,E$q,Z178c#o@X4QXc`lQId.48fo/hZd%7E#PpPn,=uD#?6k9X1RS2]T)d,$X"2G<r^ARIUimi"_]d09iGG71E93[>(DN'J4Eot^dTQb;kb&c+pu+CiqJ@'?lRDiU(Z[EF2+XVZJjRl.KOHIpPVYZRa,('gbjJkg9kX>2RR&Fn>g<5U>]*/S2PdbQO8VWoPVQV]b-oKdIk1,51`]X9Mnkt)P7sH[)R^Vp`--&M@UU&#CIpl;?~>endstream
|
| 50 |
+
endobj
|
| 51 |
+
xref
|
| 52 |
+
0 9
|
| 53 |
+
0000000000 65535 f
|
| 54 |
+
0000000061 00000 n
|
| 55 |
+
0000000102 00000 n
|
| 56 |
+
0000000209 00000 n
|
| 57 |
+
0000000321 00000 n
|
| 58 |
+
0000000524 00000 n
|
| 59 |
+
0000000592 00000 n
|
| 60 |
+
0000000872 00000 n
|
| 61 |
+
0000000931 00000 n
|
| 62 |
+
trailer
|
| 63 |
+
<<
|
| 64 |
+
/ID
|
| 65 |
+
[<3cab4607dc9367e6221bc465422d9a7a><3cab4607dc9367e6221bc465422d9a7a>]
|
| 66 |
+
% ReportLab generated PDF document -- digest (opensource)
|
| 67 |
+
|
| 68 |
+
/Info 6 0 R
|
| 69 |
+
/Root 5 0 R
|
| 70 |
+
/Size 9
|
| 71 |
+
>>
|
| 72 |
+
startxref
|
| 73 |
+
2656
|
| 74 |
+
%%EOF
|
tests/fixtures/multipage_bf.pdf
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
%PDF-1.4
|
| 2 |
+
%���� ReportLab Generated PDF document (opensource)
|
| 3 |
+
1 0 obj
|
| 4 |
+
<<
|
| 5 |
+
/F1 2 0 R
|
| 6 |
+
>>
|
| 7 |
+
endobj
|
| 8 |
+
2 0 obj
|
| 9 |
+
<<
|
| 10 |
+
/BaseFont /Helvetica /Encoding /WinAnsiEncoding /Name /F1 /Subtype /Type1 /Type /Font
|
| 11 |
+
>>
|
| 12 |
+
endobj
|
| 13 |
+
3 0 obj
|
| 14 |
+
<<
|
| 15 |
+
/Contents 9 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 8 0 R /Resources <<
|
| 16 |
+
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
| 17 |
+
>> /Rotate 0 /Trans <<
|
| 18 |
+
|
| 19 |
+
>>
|
| 20 |
+
/Type /Page
|
| 21 |
+
>>
|
| 22 |
+
endobj
|
| 23 |
+
4 0 obj
|
| 24 |
+
<<
|
| 25 |
+
/Contents 10 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 8 0 R /Resources <<
|
| 26 |
+
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
| 27 |
+
>> /Rotate 0 /Trans <<
|
| 28 |
+
|
| 29 |
+
>>
|
| 30 |
+
/Type /Page
|
| 31 |
+
>>
|
| 32 |
+
endobj
|
| 33 |
+
5 0 obj
|
| 34 |
+
<<
|
| 35 |
+
/Contents 11 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 8 0 R /Resources <<
|
| 36 |
+
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
| 37 |
+
>> /Rotate 0 /Trans <<
|
| 38 |
+
|
| 39 |
+
>>
|
| 40 |
+
/Type /Page
|
| 41 |
+
>>
|
| 42 |
+
endobj
|
| 43 |
+
6 0 obj
|
| 44 |
+
<<
|
| 45 |
+
/PageMode /UseNone /Pages 8 0 R /Type /Catalog
|
| 46 |
+
>>
|
| 47 |
+
endobj
|
| 48 |
+
7 0 obj
|
| 49 |
+
<<
|
| 50 |
+
/Author (\(anonymous\)) /CreationDate (D:20260618232230+05'00') /Creator (\(unspecified\)) /Keywords () /ModDate (D:20260618232230+05'00') /Producer (ReportLab PDF Library - \(opensource\))
|
| 51 |
+
/Subject (\(unspecified\)) /Title (\(anonymous\)) /Trapped /False
|
| 52 |
+
>>
|
| 53 |
+
endobj
|
| 54 |
+
8 0 obj
|
| 55 |
+
<<
|
| 56 |
+
/Count 3 /Kids [ 3 0 R 4 0 R 5 0 R ] /Type /Pages
|
| 57 |
+
>>
|
| 58 |
+
endobj
|
| 59 |
+
9 0 obj
|
| 60 |
+
<<
|
| 61 |
+
/Filter [ /ASCII85Decode /FlateDecode ] /Length 939
|
| 62 |
+
>>
|
| 63 |
+
stream
|
| 64 |
+
GatUs92jS!&:j6F'ml`MibE$f\V2<*FEe"2k`H(eKRlD;FSb_^gQF.V89ik15j[k#?%&$GE=dqO@Jt]K!cVD[k:[*3Tm-QJ)2$oW(<,M##V@J?8<0*9>+$m7JCK/;j2bJ`D*!m=V+F,@<4#8>0O:eeIrBL#^c<cq+<jKe3]g]aluopE>SQ=2G'SF3I"L>aMSlId[<[JfF8^UMiToh$ICVLU.Mp:mr:JtOcAHnQ.[C$9p%#6?X>c2ZI.r#t=d;IJN/`3S8?s:[Q@9+9aFqhRE9bb9VPn#FU&T"b\>cVe*+dY($7;]Z>Je9d?r7A;Fe^i^o$df8P*Oh=r,,S^_tFqM12X12_=H2m]7/M:?T7k-Ua2.WGo-B\mij0H)%+ogZYat0A#[#F\X1n(p&2,b'"Z(2Tkm'VKOENZi`FUtkLFU4d&ieV=b"$WaYdnOa<f;FOiV1*be+0U2!ngnk7jUo-]8LUC=n3'9;]`dm?He-;kA0<pj@((H#m.SKPm>!4<\2*iKu!IfihP4Cil)R669=8[$AVd9L\0rb2S%cWaQQ92OSR-P>5;98j'(`BkJ-2SWTb(#W6;Na/.3oS^`e8rZI@bQ?LG(!]qD?g%!A(AfJ2B/Bi+(\ig*_Y;s\"O9g2#"Em+@G\[j4F9\5Wke-eq%!5UT0;t\,V4C?aFukp1U;J/a`9FM7,G]ZFm55$;e?QAbD@tOcd)ZuH%Q<(Gj^qpra-b^JJIb40[sc+Hf@W61gLp$BI*`7s>AEW<FnOP_c29_a1/:Z87G3/tSsU)eBqM\;g?5R&lks^+#k:)+_5G633b!RS<'`opR8?^E@a<[5@t)@d[1OndLcK5q,k)r4#dcerh:7j@H.c3\IW=5pcMc2%=#h\ql>g(3NiKNNm-Y4L?RaN7W.OHiMjg7feH2\Ep=P:<<mQ79[DsnG?,'e>(Y_?en,~>endstream
|
| 65 |
+
endobj
|
| 66 |
+
10 0 obj
|
| 67 |
+
<<
|
| 68 |
+
/Filter [ /ASCII85Decode /FlateDecode ] /Length 944
|
| 69 |
+
>>
|
| 70 |
+
stream
|
| 71 |
+
GatUs_,B#A&4#^[MEOia0j7_cA8WOsPR[g%k0RTV!el3$Fi3_G?/)Y9Rn.eXa[#4Ze#o8gD]AmL0.d.,?3*eV3''Jn%bUrWL]h6)0Zs[8#A?Z)5tR$B:,S$(!%S0Ij+,.D9h,LrX&Q/o`J[+hj4.oA[b2[RP`dbULbOGNjDkHg]>\5;cfen[FZ7X\5kYW>@8ZlU?L@<'qL@Vclh9FhSKCpuZc.085!O[XC`9CteN[(t=hZFGX#6F.gUG4q4#l'Yn&tX#/clLt@hE4e]%GZH+]M$tUp1-Gh:7<<=$e*QheB*[@N9Am'E^1N:d_sOLVoEP0n%m%Su1Qs-@f87OpBItDXC-P@AJo])Jd1^T2km_C\P3lm,sM;829'3q!k`_TMe4GBT#XVot?FJFCer0X]^AV0R4sYM/gPSLoCtqfZrF,j6r>R[e8T[cL@R?hYZXh2P=TDdJet)q+-AY^*Ub9;?ic=Y-2`h.I=mQ<5f)99L9`A^c\`bTd:fGYZ;aQ[GnEV_<kEOE3!RF?>EQG!@a4kUdFlJ$>!F6KV])pV*)8Q141aSM@E+EA0f-G;Q4Cmae4ikmIp.u[8M_U2,(T1S'Sl*%akZ^:7Fe+7]Oo`4EPG9qDS`R8WPR)a9R>]8Z+qSTT>eKO,'ld%,T#Pn[+J5-aCAD_cLHn7s%pe%f.$rmPk__qZtNm?;$Z-rH=]^V&8cr&b#@G1hafhD8ATk7_F4Pi1Dh0PDXNg\9k\h!r$[,%Fh]R_=mlmJ,=V6pXCZ7EYT*mhrsNS&4qU*D9MAEif$4nDBH>om/pF0p&_^7KK5[2eu-aN)Ie13e?+OE11A&U#bBY)&8m0HCkO0RK[+Ce[6.PIK@HGWg=02AH.>l\DK4@ZNnr-g;n5o<Xt+e.4,VbSYdl5S5?j#i:^$)o-VD-ZM@EBOc*7pV7CiD/e/[)fS+6`8^Pro>[/~>endstream
|
| 72 |
+
endobj
|
| 73 |
+
11 0 obj
|
| 74 |
+
<<
|
| 75 |
+
/Filter [ /ASCII85Decode /FlateDecode ] /Length 984
|
| 76 |
+
>>
|
| 77 |
+
stream
|
| 78 |
+
GatUs9iKe#&4#^['ml`ME$\YmA8WOsPRa>%*-t:GTOC18RP=[<Y+2mNRn-9.a[#4Zo<+[uhQ]'pr:'"Olil=CJBt,5\:EQN\4e.^(GIIf^NuD@D*TVC(m>n$!X#M2PYXG)QFh7+i+8C8<N1XGF>0*,eo.V60drk8A"7U"K#-qs%)_taA:.D8cLC]0WqZ_91DGVQoIYOGjjX/-b/9S=nmPiGD^uWf_<Cg^$ndE7[&VLQ=S649S??IF3%<Ht:HpaV\h;m<jTD*-ZnE:i(lQu,NNF;^3_Qb@25#VP2%h.@<#iL,\C?d,%QP^"!="RoJT;6]LFjIWI+,/<QfKT-^l>dc#G^NqmOd1'PY#mXeq*GE=h4=<=fb]:GqN3nRIh!a^=WQbV%]F<LCVN'6\lJ%AlC?7?%q)gH=_AU#q"^Fdmo#1m0FenDm&QS$OG]dDTbP7-mA&[DG0>Z,$N*;*l1\]07?Jd`3flc)19''G@Lo&>/Yo(j'@CD2BkT3:b$Vu:b#2o;JBBnE$lC9SJ:U@8T!.:'I?*q*gbld/LBFTKY%Esi(e%17nb>iSde5t;Z//HQ0YlDMHUVZY#KqSV$PuA6>b,S&>_]8&bXPB0p+nQLitr_](R:p+!2Mj-af"Y"2[HSZHdjE;'2N<dfnsh-O\`"[03>\YN6$j=[.laaj+iZ;'Xb^%0=$uP5C:Eq1m'WWNbFB,@#?Q66>eCgA(m8g5#0GHph@me3:`DK_Y1W%tldTeMg=*ha4QZJ)]jms.\7]*9]TP_Y+T./,h^+'#f*UD;"lZS7)][h#l8eHfVJZ9=V#Hbkms=IUsSP&A,8fdu'M>'@2=R_*&TsdZ<=XMhoT<i1>k2Q3V2+OS]F,S,1hlYFFh"0<UbtWF%^Z.^oc:Fd8p%_q(^0k,`NLEO"n=KCuYh`O?Z<%98Y$]kB%!lX*(p]8k`sHob=kh>)/16gS1;P(q0QlXKMioZ2nC$9Qtg/d6R9!OGe\M?~>endstream
|
| 79 |
+
endobj
|
| 80 |
+
xref
|
| 81 |
+
0 12
|
| 82 |
+
0000000000 65535 f
|
| 83 |
+
0000000061 00000 n
|
| 84 |
+
0000000092 00000 n
|
| 85 |
+
0000000199 00000 n
|
| 86 |
+
0000000402 00000 n
|
| 87 |
+
0000000606 00000 n
|
| 88 |
+
0000000810 00000 n
|
| 89 |
+
0000000878 00000 n
|
| 90 |
+
0000001158 00000 n
|
| 91 |
+
0000001229 00000 n
|
| 92 |
+
0000002258 00000 n
|
| 93 |
+
0000003293 00000 n
|
| 94 |
+
trailer
|
| 95 |
+
<<
|
| 96 |
+
/ID
|
| 97 |
+
[<815189c1e19a2dc45dda1ec8d8f7c4b7><815189c1e19a2dc45dda1ec8d8f7c4b7>]
|
| 98 |
+
% ReportLab generated PDF document -- digest (opensource)
|
| 99 |
+
|
| 100 |
+
/Info 7 0 R
|
| 101 |
+
/Root 6 0 R
|
| 102 |
+
/Size 12
|
| 103 |
+
>>
|
| 104 |
+
startxref
|
| 105 |
+
4368
|
| 106 |
+
%%EOF
|
tests/fixtures/real_style_statement.pdf
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
%PDF-1.4
|
| 2 |
+
%���� ReportLab Generated PDF document (opensource)
|
| 3 |
+
1 0 obj
|
| 4 |
+
<<
|
| 5 |
+
/F1 2 0 R /F2 3 0 R
|
| 6 |
+
>>
|
| 7 |
+
endobj
|
| 8 |
+
2 0 obj
|
| 9 |
+
<<
|
| 10 |
+
/BaseFont /Helvetica /Encoding /WinAnsiEncoding /Name /F1 /Subtype /Type1 /Type /Font
|
| 11 |
+
>>
|
| 12 |
+
endobj
|
| 13 |
+
3 0 obj
|
| 14 |
+
<<
|
| 15 |
+
/BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding /Name /F2 /Subtype /Type1 /Type /Font
|
| 16 |
+
>>
|
| 17 |
+
endobj
|
| 18 |
+
4 0 obj
|
| 19 |
+
<<
|
| 20 |
+
/Contents 9 0 R /MediaBox [ 0 0 612 792 ] /Parent 8 0 R /Resources <<
|
| 21 |
+
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
| 22 |
+
>> /Rotate 0 /Trans <<
|
| 23 |
+
|
| 24 |
+
>>
|
| 25 |
+
/Type /Page
|
| 26 |
+
>>
|
| 27 |
+
endobj
|
| 28 |
+
5 0 obj
|
| 29 |
+
<<
|
| 30 |
+
/Contents 10 0 R /MediaBox [ 0 0 612 792 ] /Parent 8 0 R /Resources <<
|
| 31 |
+
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
| 32 |
+
>> /Rotate 0 /Trans <<
|
| 33 |
+
|
| 34 |
+
>>
|
| 35 |
+
/Type /Page
|
| 36 |
+
>>
|
| 37 |
+
endobj
|
| 38 |
+
6 0 obj
|
| 39 |
+
<<
|
| 40 |
+
/PageMode /UseNone /Pages 8 0 R /Type /Catalog
|
| 41 |
+
>>
|
| 42 |
+
endobj
|
| 43 |
+
7 0 obj
|
| 44 |
+
<<
|
| 45 |
+
/Author (\(anonymous\)) /CreationDate (D:20260616105547+05'00') /Creator (\(unspecified\)) /Keywords () /ModDate (D:20260616105547+05'00') /Producer (ReportLab PDF Library - \(opensource\))
|
| 46 |
+
/Subject (\(unspecified\)) /Title (\(anonymous\)) /Trapped /False
|
| 47 |
+
>>
|
| 48 |
+
endobj
|
| 49 |
+
8 0 obj
|
| 50 |
+
<<
|
| 51 |
+
/Count 2 /Kids [ 4 0 R 5 0 R ] /Type /Pages
|
| 52 |
+
>>
|
| 53 |
+
endobj
|
| 54 |
+
9 0 obj
|
| 55 |
+
<<
|
| 56 |
+
/Filter [ /ASCII85Decode /FlateDecode ] /Length 2700
|
| 57 |
+
>>
|
| 58 |
+
stream
|
| 59 |
+
Gat=.>BAQ-&q9SY^r(Fg3t-b\NPM=Kb"^Ub;G1YK1=I&k<`1ljj=';(qX;%l('*<.$-$rt$ec-7ji?2<$]lJVrkS%'ot:p?fAb<e#Z,"/6%E[/=MGNmp!Nf#J<7dZc<G!NVbF/-i\\$b_7KSiKFT.#`7anK:C6O(5G`[E?sW)`61aqB]Jt1`\QXot`VSutg#AQI]f>ob40ec`XM5Q)$R>rH$iV+Q7(QPEC**_QQlJk;#il.;/P`LXT/Jd9g2/d<>pIWQ?cDo_m3hi(*l1`@N.sR2$WSA\+92!2ph-j80@n=qjk3i8VN_`ADi]a4*u=0r3'"_fX7MPJYD^T#=>*bA[q@p_o<WZ0B8#@[/Rpm#<HSeu%SYN'S0<ZE7i7eT'2J6Ko3u]frV5!^::tkb]@h!jiJbRFF_biE39R422eQU-35'RG4?UFdUC-JU^HZWd^1kUW+.MRl[gsPa:#ukX9Q>Th^[?TTL/2mi2uGC6!U&5I\CL"^\%1ChpP[;ip@5"Lc7hhY<t\ggM)k''i'>Io0Uga'_k6GUT1?#K&0-M(^Ft()PPBIrJ-Y;&\/3l"eoga"s5&U_@$'F;Ab!1P\.R`))f*Ft*(>L'@fqac!C^C,^sV!=Wn[-l+"7Z_8ADC^/IuH;d&iF<4(15HXR)fWrHe)*p>M]@Vq5!o<tEnf&jg9>[m!Ra@Is<bbO+`@@C-*ng>G[ns*D<Sj(<Z2p$.R*TV6-aV#4F\gf,MU3VX\-)XCrBWY.>.5rH['<m"%u"SYUjgDW-oG7;GL+Am9mB.*;4;b#FU/Rb/JEr>mbA59WHiinmq23T2]-KmTa6YaV,R\rF<8a4K6ON\VN?t#uVqFj@:Xf[eBdb<up"(usBRS@bu]P[gKp>e/EqG<p"6R:ML4K4Zl<niW/nGU,%_=Q2bK&5ZJ$6#+H=O'5*1*!!W0A"t5cb%rP#:>_CD_]d/RW5n6Yh<pHN7LJ-?:,PMiV-PGhMjoteM9X9]\VAI?l/<-W3YGU@@>E$Q.jao+o'%P8mM;go(^I=[1oO^rWD$DR9N3/LLsmG:S,!<"ZNG=^<:KO[@/O:KDh7Z4+G0i,"/iTX6`R6-M6K=GXP/#Bd4p="sjC:9F7InR1br_PVDT#$,K>L4%@aU-%"tm-022+"=lY)Pj"2Wd5*0opu;(.\@DB^qfm<4kUoc.K4Y`:R\9hEA>N&j!slPPG!jN96YO+6p.g\e+h6g+%U5>-7MI+;bR?rHIQ0nmN>7mVX>'.6,R!RTWDcNS4a)\qnqNAg)mGH5.?QYg@V8M(nACJE8'."BCncTpB.$:WNm!3\.OBH.'IDfL.C#FY4[ZNm`\=HoX&BYihd/6]-IKXL.+JdC-gE$>`4A5EX*#*7.Pq?r?k\L//?[e;7bj6<Fu<<*+g)5Y,90:sE592BlN1"N&9s'GEeg/u]S1F90]^/_FnIOM+o#J@I#Se<-nGJHS.H[)B!5t3`%Z5%3?`Uf$$9*TU35&=0S:$j5GN7@Trk"h3L\@=>WLjV/'$llPs]L"NPnGY?j)Y*Jt0X_9Z\f(9;4f^N:iHgk;4>A(gKs=dfaCe)r55]pg@9#hJm*GP/+qnp,0]);JQ/GS5,l!rG*Z)_?&7Q^8<7\Sff>j6W3d;7'RZ2\tlSbUOeEoDm,]kPPF()f2t*;T;\=E&8T=UHL4,1<fP#Iq#hp#.4i^,?R)(4';d;)[+MY%Eq#SG":&kuo$;#ZCD%TB+Xomu`&^DV,dB%uKoL&+PZeS:V\:La&"?*53/"FD2*iDT'Ii)Q8PrSO>RuXdW3!9\Rb.mq7d[-@@(?J"eW+)q1Y3*sKI05OUNQpG9`l6]"=#Zpm#2uD_O35hVIs(C+gG8sS'luJ9&]<@Bd0Sj9CB1,"gMjRX5X9W/?q"bfEc+\WV*N93g!>+@j>5m_ldA\.0Q4b-+ZHAS;.E9"d`547NWT.hd/Ba-LnqFYd&?W5#FDh+kDZop"G7AD&Xk#mQ4g_`6RRU,Fh==VDlc,fKs&X<]6c\_=<8rhBd32fUB`JG=[TLh*`Iq3_?EAdE"d:ZbKOVAE3;F.3(I6U>s[N@b$r=dBuG%\jKA+a5>a9'M'^SAi-e*rf#Q^Z+tBUrGq9jD5SL;`7i&N?EYB*S%=SY;sU!9'NWIl@`.7RCi'tHN:iH'V]6"_?17'JYb@qsj@m1_;jL]9370$h<AQ\gDQ87f[0F&Yb:A?CF:_X!^b4+L*F5doNQtd=n-7,*,Io)$C.@[WT"@)B`epe+VDT-IX6gA+\2=hfYEoTi[;WFEg/Be1:Zq)V:<I9nhmH9ZY%s#r6"iF/Ep%;klE71JF@Zd<HNI>AkObfRm4(&A[+$&D;=.CUq@'fueRl5CX!SkS,i@dYE$R>R1oemX#q5G0lVD#Ye91lP*^K4g?6gegTlK&5o9sr#ise/2n"2,fk9nIOY^Gr3^I]P.2.M;s?Q8IRL45L`c5.hjZcO!NqI)-n<L@lWQ,Q".55d1KbS#deqI).%72YtW^B.>ed1;G_'OgXOA5Yi5r@jXM)2?@D-sqYT&!NtC_WZcsA;`/<@5@'kN..-f:k[4,di0!p#brL(QRdqIqI).i,)gu<9Vr)j+"!1$_WZcs,g.lP%O2ZbKH*B]`:3C6Vjt+i??$lhhXWgmd@&!(E24@=#]Ys,5\[XXL>bRa3669UhCTT.#(%8Im*>(]@^*m=[Mpgm8iaQa&dQguIi1Zpr<#VAhkp~>endstream
|
| 60 |
+
endobj
|
| 61 |
+
10 0 obj
|
| 62 |
+
<<
|
| 63 |
+
/Filter [ /ASCII85Decode /FlateDecode ] /Length 681
|
| 64 |
+
>>
|
| 65 |
+
stream
|
| 66 |
+
GasIegN)"%&:O:Slsl6Q@\o?#kqO?WR82L5E'AeMdilUTFdVFcmjfr7E<n&SZ-D\sHTNu5!Q-5<k@+O4>J]f8JlGPu6#**eq?#]Wpg(tZ1aG\-N<WU%6.1FjJ-q@c>7%4rk#\es@AO$X=r;$<I$>,0)OAtY[OD;)<HW.UPtD6n=49K,<Gg[3h0`R:61kZ:odN<9l]mg\XBudgl=I`aD9iG8E^FR6?,3gRekNh`AFbp2iCNfTrZA3>H)a=HL6,UW2>,9Hf6Ul-ij?2dofUu]"\gQVoh`2mZ59<-m)e8r-;W+<_p*!+?cRpDh^VIjJhAKu3kgr:3Aq%!P%Q\<-lci2<E_@t]=uRITnIXTR*6(3OcUn;LGCE9^FVlaBB8Ouj@IZiNXVE=88N>Bp0!EsrJqqgCu@/XbfFRnm*m/jX@=JmN#l@^s4cnkCUgEPG-AFeQ$"L%1/Oh_$<9dH+d1104tMi659qRS"-iVl=GBAt3:Q'`IFq;e_u3hnZ>oo7]VhBZYfm#fb@ks%8&8BHX9HI=gI/^G@-\*tkhr,"q*"t/YRbe7^@gO@_XI<?VR)/)B5oq#.\94iZc+nt'iN41ibTWTlf'Wf]X&I,K^fFeEOL2iOR4tJX:>Lt^'=f,A0U@N_uoT:fUmq-ScJZJ*8tc8lqbMQpQ,8]')]GVT_JcFZ@$l~>endstream
|
| 67 |
+
endobj
|
| 68 |
+
xref
|
| 69 |
+
0 11
|
| 70 |
+
0000000000 65535 f
|
| 71 |
+
0000000061 00000 n
|
| 72 |
+
0000000102 00000 n
|
| 73 |
+
0000000209 00000 n
|
| 74 |
+
0000000321 00000 n
|
| 75 |
+
0000000514 00000 n
|
| 76 |
+
0000000708 00000 n
|
| 77 |
+
0000000776 00000 n
|
| 78 |
+
0000001056 00000 n
|
| 79 |
+
0000001121 00000 n
|
| 80 |
+
0000003912 00000 n
|
| 81 |
+
trailer
|
| 82 |
+
<<
|
| 83 |
+
/ID
|
| 84 |
+
[<553088bad2e7113ee2443031be2d69a7><553088bad2e7113ee2443031be2d69a7>]
|
| 85 |
+
% ReportLab generated PDF document -- digest (opensource)
|
| 86 |
+
|
| 87 |
+
/Info 7 0 R
|
| 88 |
+
/Root 6 0 R
|
| 89 |
+
/Size 11
|
| 90 |
+
>>
|
| 91 |
+
startxref
|
| 92 |
+
4684
|
| 93 |
+
%%EOF
|
tests/fixtures/sample_statement.pdf
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
%PDF-1.4
|
| 2 |
+
%���� ReportLab Generated PDF document (opensource)
|
| 3 |
+
1 0 obj
|
| 4 |
+
<<
|
| 5 |
+
/F1 2 0 R /F2 3 0 R
|
| 6 |
+
>>
|
| 7 |
+
endobj
|
| 8 |
+
2 0 obj
|
| 9 |
+
<<
|
| 10 |
+
/BaseFont /Helvetica /Encoding /WinAnsiEncoding /Name /F1 /Subtype /Type1 /Type /Font
|
| 11 |
+
>>
|
| 12 |
+
endobj
|
| 13 |
+
3 0 obj
|
| 14 |
+
<<
|
| 15 |
+
/BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding /Name /F2 /Subtype /Type1 /Type /Font
|
| 16 |
+
>>
|
| 17 |
+
endobj
|
| 18 |
+
4 0 obj
|
| 19 |
+
<<
|
| 20 |
+
/Contents 8 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 7 0 R /Resources <<
|
| 21 |
+
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
| 22 |
+
>> /Rotate 0 /Trans <<
|
| 23 |
+
|
| 24 |
+
>>
|
| 25 |
+
/Type /Page
|
| 26 |
+
>>
|
| 27 |
+
endobj
|
| 28 |
+
5 0 obj
|
| 29 |
+
<<
|
| 30 |
+
/PageMode /UseNone /Pages 7 0 R /Type /Catalog
|
| 31 |
+
>>
|
| 32 |
+
endobj
|
| 33 |
+
6 0 obj
|
| 34 |
+
<<
|
| 35 |
+
/Author (\(anonymous\)) /CreationDate (D:20260614231936+05'00') /Creator (\(unspecified\)) /Keywords () /ModDate (D:20260614231936+05'00') /Producer (ReportLab PDF Library - \(opensource\))
|
| 36 |
+
/Subject (\(unspecified\)) /Title (\(anonymous\)) /Trapped /False
|
| 37 |
+
>>
|
| 38 |
+
endobj
|
| 39 |
+
7 0 obj
|
| 40 |
+
<<
|
| 41 |
+
/Count 1 /Kids [ 4 0 R ] /Type /Pages
|
| 42 |
+
>>
|
| 43 |
+
endobj
|
| 44 |
+
8 0 obj
|
| 45 |
+
<<
|
| 46 |
+
/Filter [ /ASCII85Decode /FlateDecode ] /Length 980
|
| 47 |
+
>>
|
| 48 |
+
stream
|
| 49 |
+
GatU29iL%:&A@ZcqV]2tb$i"s_C\/tfih2q*qkC$>qAN'K\F2U4o$:(iM3bX!Lib[qn7*t<#AHE=T1<f"8"tEhD/IY_A?UC8-j^_+RFJt=NOO$#UP0)UH,S_1L,\hc/J<&K,Id^'UQ!X+U;Ub6I&jtb6V0KC;e!n>`NFfO6nK(7M*d%Ipk0lB<T]DMDObbo4/eQ59P?+n-Z>m(S[p"]\8T!=t=A9#^]H10;RX7T_Yq8V-fOOH5I$h[':ZW6f:ai_T3hrGD":N*.f78pJ9hRdX=M3fAG$X5EjVA8pfLj".3u`UD.UTn`#MMf\<T0Sn>=1B[9&1/sA^73:0W#jcgY('&^2kAKID9Jt=$WFqsc?;7qipTg)c+id'%+$VDg1>u>t@M\UkEZIuE0#dn;E1q%fL2:3K>n:t^M,)e=S4Wp76W>H1T\<<Es)7t%@K#E(FX_6T2G?#Qtb7u@H=(0$a2nK``ar9\)GfJFK]YQ3Y`&B)6&h99KM:#q,0&O!H'"@H9[7"3AG71"/f`A)b6rIMEC61oC6X2&Ti=oR`h"&!7aNa.`Fr`;S]KF5P;NG$8l!_0ZA5NKi,fWQM`9tq!jn&2-4^L=/@XX_q:8h'f.WD(F9l8@o0LSS+^W2%9dSADE'9mjNkBSWC,<pF#ngD<U"=#"Cj)?[i9LF'oLGpSS((&\gC8.X\<<]iffa3]SeTk[q4A(b-"RAs*L<#JVFcStC,9G#8cD*GZZ(a:?qM7YEEZT!X7pA*Ao_sLC#JpWAkqG*(:N6Q)]NT:uc,&?P*r9BVo*TBim_h]jN3/;#j/,L5S*,#Ohe\cBd5=i]N?:3/8pdFYBD:5mKDZPBD]MNn4<3;*hig-Ti*UnKYBL5\$Wf%,+kd(_hVY\+CetTeo(EW5QceeEJ5HIuf`X"/!25k^7MW`4D,eK^/c+TD<53Y*\CSU3/*1^jlu4+Yd5Hd36,Pat-02t2)m"VuJ'9S0r!3'";WI~>endstream
|
| 50 |
+
endobj
|
| 51 |
+
xref
|
| 52 |
+
0 9
|
| 53 |
+
0000000000 65535 f
|
| 54 |
+
0000000061 00000 n
|
| 55 |
+
0000000102 00000 n
|
| 56 |
+
0000000209 00000 n
|
| 57 |
+
0000000321 00000 n
|
| 58 |
+
0000000524 00000 n
|
| 59 |
+
0000000592 00000 n
|
| 60 |
+
0000000872 00000 n
|
| 61 |
+
0000000931 00000 n
|
| 62 |
+
trailer
|
| 63 |
+
<<
|
| 64 |
+
/ID
|
| 65 |
+
[<19fd0a7410e5ff212aafd8cbdafa2437><19fd0a7410e5ff212aafd8cbdafa2437>]
|
| 66 |
+
% ReportLab generated PDF document -- digest (opensource)
|
| 67 |
+
|
| 68 |
+
/Info 6 0 R
|
| 69 |
+
/Root 5 0 R
|
| 70 |
+
/Size 9
|
| 71 |
+
>>
|
| 72 |
+
startxref
|
| 73 |
+
2001
|
| 74 |
+
%%EOF
|
tests/fixtures/two_block.pdf
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
%PDF-1.4
|
| 2 |
+
%���� ReportLab Generated PDF document (opensource)
|
| 3 |
+
1 0 obj
|
| 4 |
+
<<
|
| 5 |
+
/F1 2 0 R /F2 3 0 R
|
| 6 |
+
>>
|
| 7 |
+
endobj
|
| 8 |
+
2 0 obj
|
| 9 |
+
<<
|
| 10 |
+
/BaseFont /Helvetica /Encoding /WinAnsiEncoding /Name /F1 /Subtype /Type1 /Type /Font
|
| 11 |
+
>>
|
| 12 |
+
endobj
|
| 13 |
+
3 0 obj
|
| 14 |
+
<<
|
| 15 |
+
/BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding /Name /F2 /Subtype /Type1 /Type /Font
|
| 16 |
+
>>
|
| 17 |
+
endobj
|
| 18 |
+
4 0 obj
|
| 19 |
+
<<
|
| 20 |
+
/Contents 10 0 R /MediaBox [ 0 0 612 792 ] /Parent 9 0 R /Resources <<
|
| 21 |
+
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
| 22 |
+
>> /Rotate 0 /Trans <<
|
| 23 |
+
|
| 24 |
+
>>
|
| 25 |
+
/Type /Page
|
| 26 |
+
>>
|
| 27 |
+
endobj
|
| 28 |
+
5 0 obj
|
| 29 |
+
<<
|
| 30 |
+
/Contents 11 0 R /MediaBox [ 0 0 612 792 ] /Parent 9 0 R /Resources <<
|
| 31 |
+
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
| 32 |
+
>> /Rotate 0 /Trans <<
|
| 33 |
+
|
| 34 |
+
>>
|
| 35 |
+
/Type /Page
|
| 36 |
+
>>
|
| 37 |
+
endobj
|
| 38 |
+
6 0 obj
|
| 39 |
+
<<
|
| 40 |
+
/Contents 12 0 R /MediaBox [ 0 0 612 792 ] /Parent 9 0 R /Resources <<
|
| 41 |
+
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
| 42 |
+
>> /Rotate 0 /Trans <<
|
| 43 |
+
|
| 44 |
+
>>
|
| 45 |
+
/Type /Page
|
| 46 |
+
>>
|
| 47 |
+
endobj
|
| 48 |
+
7 0 obj
|
| 49 |
+
<<
|
| 50 |
+
/PageMode /UseNone /Pages 9 0 R /Type /Catalog
|
| 51 |
+
>>
|
| 52 |
+
endobj
|
| 53 |
+
8 0 obj
|
| 54 |
+
<<
|
| 55 |
+
/Author (\(anonymous\)) /CreationDate (D:20260616105547+05'00') /Creator (\(unspecified\)) /Keywords () /ModDate (D:20260616105547+05'00') /Producer (ReportLab PDF Library - \(opensource\))
|
| 56 |
+
/Subject (\(unspecified\)) /Title (\(anonymous\)) /Trapped /False
|
| 57 |
+
>>
|
| 58 |
+
endobj
|
| 59 |
+
9 0 obj
|
| 60 |
+
<<
|
| 61 |
+
/Count 3 /Kids [ 4 0 R 5 0 R 6 0 R ] /Type /Pages
|
| 62 |
+
>>
|
| 63 |
+
endobj
|
| 64 |
+
10 0 obj
|
| 65 |
+
<<
|
| 66 |
+
/Filter [ /ASCII85Decode /FlateDecode ] /Length 2335
|
| 67 |
+
>>
|
| 68 |
+
stream
|
| 69 |
+
Gat=.9iE#j&A@ZcFND9i9u^)sqZm[lSN,8V:$%NG0l%"1?jBVqb.iuogF^Y_=rgi^\?"elHh:?bq_T@hFSRZ7hfm[E*Gs<Tk=-=cS+k(8fr<4HFhBa0lbFVu-5A9ME67>jGPg?l$o>"`)09)`J24*)!N7l'SmAedlK1DrH2dC,Cnj`82_SO1*55<@LF5<ehZ9H4]0!d^?>jcMPke?s5,ukOlML7qE[PeK:VRk/mAuj[_<_-XdP;KLbQMl+WuDKTCq\:F8)"Q@2`I'^+4S^6ebRJ%/[aHU)f92VB[@:Ec_%pVhL/h:_9NMrqqL5gT,5!"J"L(&E.C'K8FYZ/$h7h\ndG.1E0F(D2DDdX"<3_ue$IWC]1-b.ZLP9m_cs"M8<_G=%WMZT+4<sHpZ'*V[E67`qu<8e=>$Sf%rP!.)q;:$g<\I%pe%)/C#-+A0<%%5?%8e<=bA;3ia"VB8kL,.M]1II=;e&=0Pm'UCn5OYKHXZJHZ)L,%H<Z_ZF<XRbs<ZMn1%01,5!SV(0oN1%t>9Gcc3,<gFr#500e?"I_&rOk%&*Dqgd/_qsEd:?#!d7*6c"n+.%0[1mZ+G)Y2SKp4@p#@qSnDPu^!-"fC*SD4X'21P+ICYKt^f?Vd<-3'99'^Dtk`ACHft@0Hn.*^;SeCBiTc6*uWXj`UuoZ9o+h^f'*g*+07M\H4UbE<.7"#_&-K:,Rh8*/n,Vkf*e#9BXAE$urO>%_0;PPm[\8B"=kt%)djb\01;"ZPVoOq*2?\Y-&K(pm;:)Lcj<E%u=h1:Y![R^p%9A<"Ku-'MF&Z(""riS4Eg8+'o^XTE>ED*=TO,J`p4k`->E-EkhjE.U7X9$TuahO,XN#Ee3/(\R9_?/oWLb_.`mhJ`mg130jq?%_0;PKaSu2:(4N6BY/?jOUD:VIZ)iAfjqZN*\8Oj1jc<)[YP$2M]`r/jGIRj:]Xi^UIYQIPVWYg^n,R%kXGfONA8*)6=rKE:9%=8YhHD?8d20*F=*+Y"@#RL'$/FHo#[OD)2L3$AI3s^)5V1\@')bMM'Kj@WJbDTa<9uI2[Kp],4_*,7'j4tC1WSCj">0]6<c81ZDRn+KVk;0Mc>@lFrKeA6%"?nG]N#f#GR2NjTI;C(^M.dJ[MSN;KajogBHf0H5b1U&t!)pE1Sm)fasZ$ThWmT)J]OD#AdeM_FF28^^@$J9gF\O4UM&]D.;,VaW54;&XH9^RQX\%gE\e)RipG%'MKKD@@DQ^d<Q@l=W!*kCj_VXIWFYl#GOr`ln0Z#N1ceBX0CQa)4X8@2uPS-q'7Z3L>Y701pS:7:I:Njk<'LhK^rqUXhmOZcT/K:N*Sb=qao^9D\F%F6jV?WdJW@9R[LJTi\hseFd'(E7"t++.Cr:V1CJgQh"^AcqC,NC63EIY[]hHuTV#3,g2^F-1\X`\T^2G')GgJkC^:N87O&k("!od+`uZsl%4=,3W`@[WaUBF/TM)6I5RIA`D$#(L.C`/Ao<SZ_]OqN@2F4_C;p_:X'@:#L7K5qeAmmC%ZLOX_oM[H;KJ#ofQ%,Mt9DXLQ$6.R8JH[p1kpM9&PebKV0#<.fkT(T^Rb/`>7/g/CC`nsTK5),ZFQC3:$SXCD"jqo7psIiL"sOY2qa<8*q`kD]>Wuk9Rh4;n8]0b.@XQX#&H*u-R=ueHqkVW\LL<FT%66ri57oqa")HRD7I[Ch00n:a'1<4#@"o-,K$n=C6a35:[Q:`b.q6C'(:;U!P:POmr50ObRY69+5mdJaD.;)]$%i%B1d?=S8,+'e]M/rSdq$qk3EPY>.GE*1<);K;BAO:t_1<f_%]hJ7Op1*G8nl`1$kDa2$`E2Oj2cOe@u5uNVk&G27'LgQ.?:5K$GJ;Sfo2BoD*`lYR\K*V?[6;qHZCRfbqM>e#<K-QPf6pZ@%4'UL]9Te`DV0?3Rg[cET`om8l%NGh9*[4LRll":.m]OYbO!FQV#kENf5;U7WV?S-(dcW`nu)(NCem[&r/Sof:d"%U;;^&o.(@8CN6_5ZQ((4d</4<n2"gRY[F#nZG-YDIrAjWHm?Q+jjaZE^1`gWn!/hXHiK(aNhYhMNV@=UGJ/_9kkPk#]QQGF@`l:s>/&!.+8UE;\E//->N;$Y]l/m7hXnN>VS(0eDiIsbp:`D%q9]#i\E.#b>N;$)hRCKVY?--pmgH,hX`;P)hRCKVmsEq1\%M*VCY\KXpT8F^ZR5F\2=>D<oY*2#q9]#i\E.#b>N;$)hRCKVY?--pn!e[Ygq1S_1p5EZC.YNE?4hSb%3h;PAt<4LTm"AYYAb08ZMTuTLbS6u!TLEBGOH.2imlT;l?6Mem_[MGWXfY[,fOkJpC<?Ip_Vu@=dB~>endstream
|
| 70 |
+
endobj
|
| 71 |
+
11 0 obj
|
| 72 |
+
<<
|
| 73 |
+
/Filter [ /ASCII85Decode /FlateDecode ] /Length 2280
|
| 74 |
+
>>
|
| 75 |
+
stream
|
| 76 |
+
Gat=.9lh:3&A@sBkeJ?<_aJ6f+78!J5]@?_.5"k!]Eq?dS)A)t`P4psm?V@D>E)Q1P-1CSm[7_\o@#B_l12m]rdK'leB(<GI;Y8M[<f$8S"3`r;Q$Rj^Q&^H@o?/k!?]!<b_B(iPapUlmQMVU@]%[/Q#963:aX`H!4B,YZcfd^?!XEWcX38NXOPqnlK.?<j&bO,q!;Yd4$Za]gWT!\G.lQYpf=L\Wmu=YqrPt@Ir>kc`csR\k>ebN^ULRs5.u;,^o(DlBM`+&SN]&:J%4?OXu@HPalUe/k1uJ0+6&8<F6S:7ITV+!o]:DrKu!sT4!<m.f@rNnmOnOR]OB^uGtsL[Vjf4,AqKS8lo.s@>3f>WiCtQL^uX8N]YA4bV^WQL4$UAh\dLUqqfbne83`C>4s%sTRiIBus6ocEjc]K;BN$D7TPg%FIa4h12sOk=eig&EP.IFBonNm=4kS1/hP[]`UsoSFW8W_sQ%=5b_fc@%ed&@E0Z6V&mqp0-+BIAA)Zsda.R,UHFdFG(PBt=I%9Fn:i?*csZ`snrqTHL_ld+>O:E/H:SEt'%?Z<`.^V3pY>7I[)K3?e*j*Jm6A(^s`+eRSmY;iI<G(rFcBt#%fK5('ZS#6h;9Ha,TlO3CJfCNW.N\=/X0;gZe.q!oC>^V"sgm#oid#A`-gGiDT;NR,+EkhgD!^GcHF(!,o<Rm2@!/V'b5Kj:i\Y-p_"bS-S#[KJ]7U_Ad7.OSZWQKpG3V-KDhmfm7(C33Rp-QI'r9*Lsc&G%1(O3`LaFb4PfVA8?a(f3Y?s#.$<$3+='MF&Z'g=N*S4Eg8+5Rc.TE>uT*=TO,J`p2uL9N9'a8m)ZWc/Q-/atk=2E*8$a9@/;k(;jF\A6))"fDC>"enq_j&doF3T's3'eXle3AK#!+EZiV+r:N6r.r.>Cbf97&&<uE4Zugr,N:s0&O]!X>]I%O.Web;-rQa/ar(@DoRV$<kXGfONA8*)6>!SMSQMqS@C9Zr.g9K_Qd-k=6/[O?87ODg0p9AGM$u_?lE\E]JP_pHW`@+oE?lAZFRdR!)$k]`OO-<>+q$dA7Ln\IZ!&$.@bde'+ga+j/i,AX_DU^;>,9#p[*rt!%H>8W`KNYJ4qMR9.OU&B`c-NU;QXte9%hIZDASs_l=iZH8RR8^_0OIDA4(=-KJl-BBri0X*O(+&$6.N*!%X^R6"<9hScjN?R$a^p)SpS@RX]Xb<lQCFGt\cd@1"=kW.<[adtl;5^s'f+$mkF0X-!q/IM1?[cV?JuK2A-KW`@\jedThL&=\+ed<lRo@7,"+Zl7U/gaAdiNcQ8"#!rQ2@'.:8/^HGe!q]bb7Jf[77ums.6m;d"M.lnQZ'q)dp>VGNg:GDY1nqj0r=n'8JXMM6/8fjF"8rob.%p[a=^0Mq:=)e:ASjH1L7.p9K_ji0Yot#^!]+Wu%[:!=)hJ/<1g%d>0o6>N*%9>eCkQ=g&urQsM@PldNuo$`TUpc46o=r9RL\d(JikB9[XJR0o\7<[Y+MHg,VZPZ(gBRZct_W/0VO.P"4,+S;M.f;%B<F\YV$D:W!Q0_6+!9fB&7J5(6hkgg#15:NBi8:r_uL+"Mo[fXhogQr3,AeoM%Y<[%IPNoU*ul,XQIBLut>ddfDY!T<pF%KXPmK0KP[?g[l.Is%-U1@gMNeBf5H)Qr$+:%arNrKFZYM=uK%d4DX?mcnt\(d=N!u@7.8lZl7U//1@3ocKrr9fJ%m\D7.7Ab3rC1j\B\#$DIXI?[71J9BcSqHKObf4k&XYLEoV#Jo-rm)SP*e>rY:5)2GdP]_@?iZ71#o"P\KA7T,s'RT(_1g7,p`4`nd[?"Y"B:2VZR&.VZ<n=0cmY'@mi8-HbKL*i9bo8]re-#Pid00I#?\F'i[;L6kR5--h,X65`/pV*SiGm\ihIn$*SUhhiB.Oi$oKEI@$S0mN)H#V_"-V1.W:HVa`K@<=,8C:n,$ml5ao0=N(HV(N_h_kdnTF3"Qq>U*ts6h`HLS#`7\,2-"(QI7iLS#RYbT=T1krRog5f]2#jgFFM&l[Y`d.0&3q2fndUn7=oCQ2QTp:`D%H.2ZAR.<00^,9g(f_a70j[8K@j-Fo&DiIsBHJ]XM]O(6\]e?2<\&L!AHf#aN]^L8ah"%oJX`;ORhRCMlZ]X&?f!p3)dehV*j[8K@j-Fo&DiIsBHJ]XM]O(6\]l3=UF71#oe%"%/M_2>EGX:itcXZHa0^J(#!bt46qsH?smUgk@Kac!".R;c`p&62(ID!'.>b0s3`I57`Vg:DDPn_]tr<G7Gk[0G1+3+~>endstream
|
| 77 |
+
endobj
|
| 78 |
+
12 0 obj
|
| 79 |
+
<<
|
| 80 |
+
/Filter [ /ASCII85Decode /FlateDecode ] /Length 487
|
| 81 |
+
>>
|
| 82 |
+
stream
|
| 83 |
+
Gas1]b>,r/&A7ljp'@U;GO<=DO7WTB*2)?t6:k%ToKcXenb5#O@u#k><YFlFm!?=L9Rp`:IBWAFdi?A#!YUT5J.eG=rX=^TI51:b&fFcDFCt.L7LRh?b\(NG;>;sM[Wr3J$Mb`\k/F10KmX.$>u-VW36A*Lcg,n(\nmF_s5fJ:/p5OEb"Z$*L\3oP+XL4>WW'WF$n*uRbZ`u,/jG)TOsCjKk-V(E_=\OV/&GgJE@fVi(-(RWlk*Q]TDDYi0J?5Bo8o$A'/".0=;%@Hl[?"k_9KSa@YA:/JP,=tSMn=3,jgGX;tFOk*TJTs6kT(+Ws<7^fJbmZMItamOD2S<Am3$?74C#@FqM*3_&_GhXK7cT64?7Q_%)9N=6_4JC&`sbr&g2kX_lZAf`m!B6JF\.[LFF]P,?KaFRs/b[I($dj+G-n(DmSbZ`OK!p^(JH!BYHKkq$4a'AMg^@)n3R?gWYNK=MY[;U)oEb(0ElB^>"O~>endstream
|
| 84 |
+
endobj
|
| 85 |
+
xref
|
| 86 |
+
0 13
|
| 87 |
+
0000000000 65535 f
|
| 88 |
+
0000000061 00000 n
|
| 89 |
+
0000000102 00000 n
|
| 90 |
+
0000000209 00000 n
|
| 91 |
+
0000000321 00000 n
|
| 92 |
+
0000000515 00000 n
|
| 93 |
+
0000000709 00000 n
|
| 94 |
+
0000000903 00000 n
|
| 95 |
+
0000000971 00000 n
|
| 96 |
+
0000001251 00000 n
|
| 97 |
+
0000001322 00000 n
|
| 98 |
+
0000003749 00000 n
|
| 99 |
+
0000006121 00000 n
|
| 100 |
+
trailer
|
| 101 |
+
<<
|
| 102 |
+
/ID
|
| 103 |
+
[<716c12eb22d1f7b21e7eea952165728c><716c12eb22d1f7b21e7eea952165728c>]
|
| 104 |
+
% ReportLab generated PDF document -- digest (opensource)
|
| 105 |
+
|
| 106 |
+
/Info 8 0 R
|
| 107 |
+
/Root 7 0 R
|
| 108 |
+
/Size 13
|
| 109 |
+
>>
|
| 110 |
+
startxref
|
| 111 |
+
6699
|
| 112 |
+
%%EOF
|
tests/generators/make_complex_pdf.py
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
make_complex_pdf.py — generate a BIG, complex, multi-page bank statement PDF
|
| 3 |
+
that must still reconcile and pass the (unedited) converter.
|
| 4 |
+
|
| 5 |
+
Complexity: ~120 transactions across multiple pages, large comma-separated
|
| 6 |
+
amounts, long/messy descriptions, mixed debits & credits. Balances are computed
|
| 7 |
+
in integer cents so reconciliation is exact (no float drift). The header row is
|
| 8 |
+
repeated on every page so the unedited converter parses every page's table.
|
| 9 |
+
"""
|
| 10 |
+
from __future__ import annotations
|
| 11 |
+
|
| 12 |
+
import random
|
| 13 |
+
from pathlib import Path
|
| 14 |
+
|
| 15 |
+
from reportlab.lib import colors
|
| 16 |
+
from reportlab.lib.pagesizes import A4
|
| 17 |
+
from reportlab.lib.styles import getSampleStyleSheet
|
| 18 |
+
from reportlab.lib.units import mm
|
| 19 |
+
from reportlab.platypus import (
|
| 20 |
+
SimpleDocTemplate, Paragraph, Spacer, Table, TableStyle,
|
| 21 |
+
)
|
| 22 |
+
|
| 23 |
+
HERE = Path(__file__).resolve().parent
|
| 24 |
+
OUT_PDF = HERE / "complex_statement.pdf"
|
| 25 |
+
|
| 26 |
+
random.seed(42)
|
| 27 |
+
|
| 28 |
+
MERCHANTS = [
|
| 29 |
+
"AMAZON MARKETPLACE EU", "WOOLWORTHS 1234 SYDNEY NSW", "UBER *TRIP HELP.UBER.COM",
|
| 30 |
+
"DIRECT DEBIT - AGL ENERGY LTD", "SALARY - ACME CORP PTY LTD PAYROLL",
|
| 31 |
+
"TFR TO 062-000 12345678 RENT", "PAYPAL *STEAMGAMES 35314369001",
|
| 32 |
+
"BPAY To ASIC INV 9920183", "STRIPE TRANSFER ST-X8K2Q9", "ATM WDL CBA GEORGE ST",
|
| 33 |
+
"INTEREST PAID - CMA ACCOUNT", "NETFLIX.COM SUBSCRIPTION", "COLES 0456 CHATSWOOD",
|
| 34 |
+
"INTL TXN GITHUB.COM USD FEE", "DIVIDEND - VAS ETF DISTRIBUTION",
|
| 35 |
+
"TFR FROM SAVINGS 062-111 99887766", "QANTAS AIRWAYS DOMESTIC FARE",
|
| 36 |
+
"MEDICARE BENEFIT DEPOSIT", "OPTUS MOBILE POSTPAID 0400111222",
|
| 37 |
+
"REFUND - APPLE STORE ONLINE",
|
| 38 |
+
]
|
| 39 |
+
REFS = ["REF", "INV", "TXN", "ORD", "PMT", "RCPT"]
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
def gen_transactions(n=120):
|
| 43 |
+
txns = []
|
| 44 |
+
day = 1
|
| 45 |
+
month = 1
|
| 46 |
+
year = 2025
|
| 47 |
+
for i in range(n):
|
| 48 |
+
# advance the date a little each row
|
| 49 |
+
day += random.randint(0, 3)
|
| 50 |
+
if day > 28:
|
| 51 |
+
day -= 28
|
| 52 |
+
month += 1
|
| 53 |
+
if month > 12:
|
| 54 |
+
month = 1
|
| 55 |
+
year += 1
|
| 56 |
+
merchant = random.choice(MERCHANTS)
|
| 57 |
+
ref = f"{random.choice(REFS)}{random.randint(10000, 99999)}"
|
| 58 |
+
desc = f"{merchant} {ref}"
|
| 59 |
+
is_credit = random.random() < 0.42
|
| 60 |
+
if "SALARY" in merchant or "DIVIDEND" in merchant or "INTEREST" in merchant or "REFUND" in merchant or "BENEFIT" in merchant:
|
| 61 |
+
is_credit = True
|
| 62 |
+
if "BPAY" in merchant or "ATM" in merchant or "DIRECT DEBIT" in merchant or "RENT" in merchant:
|
| 63 |
+
is_credit = False
|
| 64 |
+
cents = random.randint(150, 850000) # $1.50 .. $8,500.00
|
| 65 |
+
txns.append({
|
| 66 |
+
"date": f"{day:02d}/{month:02d}/{year}",
|
| 67 |
+
"description": desc,
|
| 68 |
+
"debit_c": 0 if is_credit else cents,
|
| 69 |
+
"credit_c": cents if is_credit else 0,
|
| 70 |
+
})
|
| 71 |
+
return txns
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
def money(cents: int) -> str:
|
| 75 |
+
return f"${cents / 100:,.2f}"
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
def build_pdf(txns, out_path: Path, opening_cents: int = 5_000_000):
|
| 79 |
+
styles = getSampleStyleSheet()
|
| 80 |
+
doc = SimpleDocTemplate(
|
| 81 |
+
str(out_path), pagesize=A4,
|
| 82 |
+
topMargin=16 * mm, bottomMargin=16 * mm,
|
| 83 |
+
leftMargin=14 * mm, rightMargin=14 * mm,
|
| 84 |
+
)
|
| 85 |
+
|
| 86 |
+
running = opening_cents
|
| 87 |
+
rows = [["Date", "Description", "Debit", "Credit", "Balance"]]
|
| 88 |
+
for t in txns:
|
| 89 |
+
running += t["credit_c"] - t["debit_c"]
|
| 90 |
+
tag = "CR" if running >= 0 else "DR"
|
| 91 |
+
rows.append([
|
| 92 |
+
t["date"],
|
| 93 |
+
t["description"],
|
| 94 |
+
money(t["debit_c"]) if t["debit_c"] else "",
|
| 95 |
+
money(t["credit_c"]) if t["credit_c"] else "",
|
| 96 |
+
f"{money(abs(running))} {tag}",
|
| 97 |
+
])
|
| 98 |
+
closing = running
|
| 99 |
+
|
| 100 |
+
period = f"{txns[0]['date']} to {txns[-1]['date']}"
|
| 101 |
+
story = [
|
| 102 |
+
Paragraph("<b>Macquarie Cash Management Account</b>", styles["Title"]),
|
| 103 |
+
Spacer(1, 6),
|
| 104 |
+
Paragraph("Account: 987654321 - BUSINESS CASH MANAGEMENT ACCOUNT", styles["Normal"]),
|
| 105 |
+
Paragraph("Account Holder: COMPLEXICORP HOLDINGS PTY LTD", styles["Normal"]),
|
| 106 |
+
Paragraph(f"Statement Period: {period}", styles["Normal"]),
|
| 107 |
+
Paragraph(f"Opening Balance: {money(opening_cents)}", styles["Normal"]),
|
| 108 |
+
Paragraph(f"Closing Balance: {money(closing)}", styles["Normal"]),
|
| 109 |
+
Spacer(1, 10),
|
| 110 |
+
]
|
| 111 |
+
|
| 112 |
+
tbl = Table(
|
| 113 |
+
rows,
|
| 114 |
+
colWidths=[22 * mm, 78 * mm, 26 * mm, 26 * mm, 30 * mm],
|
| 115 |
+
repeatRows=1, # repeat header on every page so each page's table parses
|
| 116 |
+
)
|
| 117 |
+
tbl.setStyle(TableStyle([
|
| 118 |
+
("GRID", (0, 0), (-1, -1), 0.4, colors.grey),
|
| 119 |
+
("BACKGROUND", (0, 0), (-1, 0), colors.HexColor("#1f3a5f")),
|
| 120 |
+
("TEXTCOLOR", (0, 0), (-1, 0), colors.white),
|
| 121 |
+
("FONTNAME", (0, 0), (-1, 0), "Helvetica-Bold"),
|
| 122 |
+
("FONTSIZE", (0, 0), (-1, -1), 7),
|
| 123 |
+
("ALIGN", (2, 0), (-1, -1), "RIGHT"),
|
| 124 |
+
("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
|
| 125 |
+
]))
|
| 126 |
+
story.append(tbl)
|
| 127 |
+
doc.build(story)
|
| 128 |
+
|
| 129 |
+
credits = sum(t["credit_c"] for t in txns)
|
| 130 |
+
debits = sum(t["debit_c"] for t in txns)
|
| 131 |
+
return closing, credits, debits
|
| 132 |
+
|
| 133 |
+
|
| 134 |
+
if __name__ == "__main__":
|
| 135 |
+
txns = gen_transactions(120)
|
| 136 |
+
closing, credits, debits = build_pdf(txns, OUT_PDF)
|
| 137 |
+
print(f"Wrote {OUT_PDF}")
|
| 138 |
+
print(f"{len(txns)} transactions")
|
| 139 |
+
print(f"opening $50,000.00 + credits {money(credits)} - debits {money(debits)} = closing {money(closing)}")
|
tests/generators/make_hard_tests.py
ADDED
|
@@ -0,0 +1,200 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
make_hard_tests.py — adversarial PDFs to try to BREAK the unedited converter.
|
| 3 |
+
|
| 4 |
+
Each function builds one statement that stresses a different real-world failure
|
| 5 |
+
mode. We then run converter.py on each and see what survives.
|
| 6 |
+
"""
|
| 7 |
+
from __future__ import annotations
|
| 8 |
+
|
| 9 |
+
import random
|
| 10 |
+
from pathlib import Path
|
| 11 |
+
|
| 12 |
+
from reportlab.lib import colors
|
| 13 |
+
from reportlab.lib.pagesizes import A4
|
| 14 |
+
from reportlab.lib.styles import getSampleStyleSheet
|
| 15 |
+
from reportlab.lib.units import mm
|
| 16 |
+
from reportlab.platypus import SimpleDocTemplate, Paragraph, Spacer, Table, TableStyle
|
| 17 |
+
|
| 18 |
+
HERE = Path(__file__).resolve().parent
|
| 19 |
+
OUT = HERE / "hard"
|
| 20 |
+
OUT.mkdir(exist_ok=True)
|
| 21 |
+
random.seed(7)
|
| 22 |
+
|
| 23 |
+
DESCS = ["AMAZON MKTPLACE", "WOOLWORTHS 1234", "SALARY ACME CORP", "BPAY ASIC",
|
| 24 |
+
"UBER TRIP", "AGL ENERGY DD", "NETFLIX SUB", "INTEREST PAID",
|
| 25 |
+
"TFR RENT 062000", "DIVIDEND VAS ETF", "COLES 0456", "OPTUS MOBILE"]
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def gen(n=15, opening_c=1_000_000):
|
| 29 |
+
rows, running = [], opening_c
|
| 30 |
+
d = 1
|
| 31 |
+
for i in range(n):
|
| 32 |
+
d += random.randint(1, 2)
|
| 33 |
+
is_cr = random.random() < 0.45
|
| 34 |
+
c = random.randint(500, 400000)
|
| 35 |
+
running += c if is_cr else -c
|
| 36 |
+
rows.append({
|
| 37 |
+
"date": f"{min(d,28):02d}/03/2025",
|
| 38 |
+
"desc": f"{random.choice(DESCS)} REF{random.randint(1000,9999)}",
|
| 39 |
+
"debit_c": 0 if is_cr else c,
|
| 40 |
+
"credit_c": c if is_cr else 0,
|
| 41 |
+
"bal_c": running,
|
| 42 |
+
})
|
| 43 |
+
return rows, opening_c, running
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def usd(c): return f"${c/100:,.2f}"
|
| 47 |
+
def eur(c): return f"€{c/100:,.2f}".replace(",", "X").replace(".", ",").replace("X", ".") # 1.234,56
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def build(path, header, data_rows, meta_lines, grid=True):
|
| 51 |
+
styles = getSampleStyleSheet()
|
| 52 |
+
doc = SimpleDocTemplate(str(path), pagesize=A4, topMargin=16*mm, bottomMargin=16*mm,
|
| 53 |
+
leftMargin=14*mm, rightMargin=14*mm)
|
| 54 |
+
story = [Paragraph("<b>Test Bank Statement</b>", styles["Title"]), Spacer(1, 6)]
|
| 55 |
+
for m in meta_lines:
|
| 56 |
+
story.append(Paragraph(m, styles["Normal"]))
|
| 57 |
+
story.append(Spacer(1, 10))
|
| 58 |
+
tbl = Table([header] + data_rows, repeatRows=1)
|
| 59 |
+
style = [("FONTSIZE", (0, 0), (-1, -1), 7), ("ALIGN", (2, 0), (-1, -1), "RIGHT")]
|
| 60 |
+
if grid:
|
| 61 |
+
style += [("GRID", (0, 0), (-1, -1), 0.4, colors.grey),
|
| 62 |
+
("BACKGROUND", (0, 0), (-1, 0), colors.HexColor("#1f3a5f")),
|
| 63 |
+
("TEXTCOLOR", (0, 0), (-1, 0), colors.white)]
|
| 64 |
+
tbl.setStyle(TableStyle(style))
|
| 65 |
+
story.append(tbl)
|
| 66 |
+
doc.build(story)
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
def std_meta(op_c, cl_c, op_label="Opening Balance", cl_label="Closing Balance"):
|
| 70 |
+
return ["Account: 111222333 - TEST ACCOUNT", "Account Holder: TEST USER PTY LTD",
|
| 71 |
+
"Statement Period: 01/03/2025 to 31/03/2025",
|
| 72 |
+
f"{op_label}: {usd(op_c)}", f"{cl_label}: {usd(cl_c)}"]
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
# ---- T1: borderless (no gridlines) ----
|
| 76 |
+
def t1_no_gridlines():
|
| 77 |
+
rows, op, cl = gen()
|
| 78 |
+
data = [[r["date"], r["desc"], usd(r["debit_c"]) if r["debit_c"] else "",
|
| 79 |
+
usd(r["credit_c"]) if r["credit_c"] else "", f"{usd(r['bal_c'])} CR"] for r in rows]
|
| 80 |
+
build(OUT/"t1_no_gridlines.pdf", ["Date","Description","Debit","Credit","Balance"],
|
| 81 |
+
data, std_meta(op, cl), grid=False)
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
# ---- T2: scanned / image-only (no text layer) ----
|
| 85 |
+
def t2_scanned():
|
| 86 |
+
try:
|
| 87 |
+
from PIL import Image, ImageDraw, ImageFont
|
| 88 |
+
from reportlab.pdfgen import canvas
|
| 89 |
+
except Exception as e:
|
| 90 |
+
print(" (skip t2 scanned — PIL missing:", e, ")"); return
|
| 91 |
+
|
| 92 |
+
def font(sz):
|
| 93 |
+
for p in ("/System/Library/Fonts/Supplemental/Arial.ttf", "/Library/Fonts/Arial.ttf",
|
| 94 |
+
"/System/Library/Fonts/Helvetica.ttc"):
|
| 95 |
+
try:
|
| 96 |
+
return ImageFont.truetype(p, sz)
|
| 97 |
+
except Exception:
|
| 98 |
+
pass
|
| 99 |
+
return ImageFont.load_default(size=sz)
|
| 100 |
+
|
| 101 |
+
rows, op, cl = gen()
|
| 102 |
+
W, H = 1654, 2339 # A4 @ ~200dpi, crisp TrueType text = realistic clean scan
|
| 103 |
+
img = Image.new("RGB", (W, H), "white"); dr = ImageDraw.Draw(img)
|
| 104 |
+
f, fb = font(30), font(42)
|
| 105 |
+
y = 60
|
| 106 |
+
dr.text((60, y), "Test Bank Statement", fill="black", font=fb); y += 80
|
| 107 |
+
for line in std_meta(op, cl):
|
| 108 |
+
dr.text((60, y), line, fill="black", font=f); y += 46
|
| 109 |
+
y += 24
|
| 110 |
+
cols = [60, 320, 780, 1040, 1300]
|
| 111 |
+
for x, h in zip(cols, ["Date", "Description", "Debit", "Credit", "Balance"]):
|
| 112 |
+
dr.text((x, y), h, fill="black", font=f)
|
| 113 |
+
y += 54
|
| 114 |
+
for r in rows:
|
| 115 |
+
cells = [r["date"], r["desc"][:22], usd(r["debit_c"]) if r["debit_c"] else "",
|
| 116 |
+
usd(r["credit_c"]) if r["credit_c"] else "", f"{usd(r['bal_c'])} CR"]
|
| 117 |
+
for x, cell in zip(cols, cells):
|
| 118 |
+
dr.text((x, y), cell, fill="black", font=f)
|
| 119 |
+
y += 44
|
| 120 |
+
png = OUT/"t2_scan.png"; img.save(png)
|
| 121 |
+
c = canvas.Canvas(str(OUT/"t2_scanned.pdf"), pagesize=A4)
|
| 122 |
+
c.drawImage(str(png), 0, 0, width=A4[0], height=A4[1]); c.save()
|
| 123 |
+
|
| 124 |
+
|
| 125 |
+
# ---- T3: renamed headers ----
|
| 126 |
+
def t3_renamed_headers():
|
| 127 |
+
rows, op, cl = gen()
|
| 128 |
+
data = [[r["date"], r["desc"], usd(r["debit_c"]) if r["debit_c"] else "",
|
| 129 |
+
usd(r["credit_c"]) if r["credit_c"] else "", f"{usd(r['bal_c'])} CR"] for r in rows]
|
| 130 |
+
build(OUT/"t3_renamed_headers.pdf",
|
| 131 |
+
["Transaction Date","Details","Withdrawals","Deposits","Running Balance"],
|
| 132 |
+
data, std_meta(op, cl))
|
| 133 |
+
|
| 134 |
+
|
| 135 |
+
# ---- T4: single signed Amount column ----
|
| 136 |
+
def t4_signed_amount():
|
| 137 |
+
rows, op, cl = gen()
|
| 138 |
+
data = [[r["date"], r["desc"],
|
| 139 |
+
usd(r["credit_c"]) if r["credit_c"] else f"-{usd(r['debit_c'])}",
|
| 140 |
+
f"{usd(r['bal_c'])} CR"] for r in rows]
|
| 141 |
+
build(OUT/"t4_signed_amount.pdf", ["Date","Description","Amount","Balance"],
|
| 142 |
+
data, std_meta(op, cl))
|
| 143 |
+
|
| 144 |
+
|
| 145 |
+
# ---- T5: European number format + euro symbol ----
|
| 146 |
+
def t5_european_format():
|
| 147 |
+
rows, op, cl = gen()
|
| 148 |
+
data = [[r["date"], r["desc"], eur(r["debit_c"]) if r["debit_c"] else "",
|
| 149 |
+
eur(r["credit_c"]) if r["credit_c"] else "", f"{eur(r['bal_c'])} CR"] for r in rows]
|
| 150 |
+
meta = ["Account: 111222333 - TEST ACCOUNT", "Account Holder: TEST USER",
|
| 151 |
+
"Statement Period: 01/03/2025 to 31/03/2025",
|
| 152 |
+
f"Opening Balance: {eur(op)}", f"Closing Balance: {eur(cl)}"]
|
| 153 |
+
build(OUT/"t5_european_format.pdf", ["Date","Description","Debit","Credit","Balance"], data, meta)
|
| 154 |
+
|
| 155 |
+
|
| 156 |
+
# ---- T6: two accounts in one PDF ----
|
| 157 |
+
def t6_multi_account():
|
| 158 |
+
styles = getSampleStyleSheet()
|
| 159 |
+
doc = SimpleDocTemplate(str(OUT/"t6_multi_account.pdf"), pagesize=A4,
|
| 160 |
+
topMargin=16*mm, bottomMargin=16*mm, leftMargin=14*mm, rightMargin=14*mm)
|
| 161 |
+
story = [Paragraph("<b>Test Bank Statement</b>", styles["Title"]), Spacer(1, 6)]
|
| 162 |
+
for acc in ("Account: AAA-111", "Account: BBB-222"):
|
| 163 |
+
rows, op, cl = gen()
|
| 164 |
+
for m in [acc, f"Opening Balance: {usd(op)}", f"Closing Balance: {usd(cl)}"]:
|
| 165 |
+
story.append(Paragraph(m, styles["Normal"]))
|
| 166 |
+
story.append(Spacer(1, 6))
|
| 167 |
+
data = [["Date","Description","Debit","Credit","Balance"]] + [
|
| 168 |
+
[r["date"], r["desc"], usd(r["debit_c"]) if r["debit_c"] else "",
|
| 169 |
+
usd(r["credit_c"]) if r["credit_c"] else "", f"{usd(r['bal_c'])} CR"] for r in rows]
|
| 170 |
+
t = Table(data, repeatRows=1)
|
| 171 |
+
t.setStyle(TableStyle([("GRID",(0,0),(-1,-1),0.4,colors.grey),("FONTSIZE",(0,0),(-1,-1),7)]))
|
| 172 |
+
story.append(t); story.append(Spacer(1, 14))
|
| 173 |
+
doc.build(story)
|
| 174 |
+
|
| 175 |
+
|
| 176 |
+
# ---- T7: relabeled balance lines (no "Opening Balance:") ----
|
| 177 |
+
def t7_diff_balance_labels():
|
| 178 |
+
rows, op, cl = gen()
|
| 179 |
+
data = [[r["date"], r["desc"], usd(r["debit_c"]) if r["debit_c"] else "",
|
| 180 |
+
usd(r["credit_c"]) if r["credit_c"] else "", f"{usd(r['bal_c'])} CR"] for r in rows]
|
| 181 |
+
build(OUT/"t7_diff_balance_labels.pdf", ["Date","Description","Debit","Credit","Balance"],
|
| 182 |
+
data, std_meta(op, cl, "Balance Brought Forward", "Balance Carried Forward"))
|
| 183 |
+
|
| 184 |
+
|
| 185 |
+
# ---- T8: text-month dates (should still survive) ----
|
| 186 |
+
def t8_text_dates():
|
| 187 |
+
rows, op, cl = gen()
|
| 188 |
+
months = {3: "Mar"}
|
| 189 |
+
data = [[f"{i+1:02d} Mar 2025", r["desc"], usd(r["debit_c"]) if r["debit_c"] else "",
|
| 190 |
+
usd(r["credit_c"]) if r["credit_c"] else "", f"{usd(r['bal_c'])} CR"]
|
| 191 |
+
for i, r in enumerate(rows)]
|
| 192 |
+
build(OUT/"t8_text_dates.pdf", ["Date","Description","Debit","Credit","Balance"],
|
| 193 |
+
data, std_meta(op, cl))
|
| 194 |
+
|
| 195 |
+
|
| 196 |
+
if __name__ == "__main__":
|
| 197 |
+
for fn in (t1_no_gridlines, t2_scanned, t3_renamed_headers, t4_signed_amount,
|
| 198 |
+
t5_european_format, t6_multi_account, t7_diff_balance_labels, t8_text_dates):
|
| 199 |
+
fn()
|
| 200 |
+
print("built:", fn.__name__)
|
tests/generators/make_multipage_bf.py
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Repro fixture: ONE statement spread over several pages, repeating its account
|
| 2 |
+
header and a 'Balance Brought Forward' line on every page (as many real bank
|
| 3 |
+
PDFs — incl. the Indian statement that exposed this — do).
|
| 4 |
+
|
| 5 |
+
The engine must treat this as a SINGLE statement, not one-per-page.
|
| 6 |
+
"""
|
| 7 |
+
from pathlib import Path
|
| 8 |
+
|
| 9 |
+
from reportlab.lib import colors
|
| 10 |
+
from reportlab.lib.pagesizes import A4
|
| 11 |
+
from reportlab.lib.styles import getSampleStyleSheet
|
| 12 |
+
from reportlab.platypus import (
|
| 13 |
+
PageBreak,
|
| 14 |
+
Paragraph,
|
| 15 |
+
SimpleDocTemplate,
|
| 16 |
+
Spacer,
|
| 17 |
+
Table,
|
| 18 |
+
TableStyle,
|
| 19 |
+
)
|
| 20 |
+
|
| 21 |
+
OUT = Path(__file__).resolve().parents[1] / "fixtures" / "multipage_bf.pdf"
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def money(cents: int) -> str:
|
| 25 |
+
return f"{cents / 100:,.2f}"
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def main():
|
| 29 |
+
styles = getSampleStyleSheet()
|
| 30 |
+
rows_per_page, pages = 10, 3
|
| 31 |
+
opening = 5_000_000
|
| 32 |
+
running = opening
|
| 33 |
+
credits = debits = 0
|
| 34 |
+
txn = 0
|
| 35 |
+
story = []
|
| 36 |
+
|
| 37 |
+
for p in range(pages):
|
| 38 |
+
# repeated per-page header — the over-sectioning trigger
|
| 39 |
+
story.append(Paragraph("Account: 555000111 - ACME CURRENT ACCOUNT", styles["Normal"]))
|
| 40 |
+
story.append(Paragraph("Account Holder: ACME TRADING LTD", styles["Normal"]))
|
| 41 |
+
if p == 0:
|
| 42 |
+
story.append(Paragraph(f"Opening Balance: {money(opening)}", styles["Normal"]))
|
| 43 |
+
else:
|
| 44 |
+
story.append(Paragraph(f"Balance Brought Forward: {money(running)}", styles["Normal"]))
|
| 45 |
+
story.append(Spacer(1, 8))
|
| 46 |
+
|
| 47 |
+
data = [["Date", "Description", "Debit", "Credit", "Balance"]]
|
| 48 |
+
for _ in range(rows_per_page):
|
| 49 |
+
txn += 1
|
| 50 |
+
if txn % 3 == 0:
|
| 51 |
+
amt = 150_000
|
| 52 |
+
running += amt
|
| 53 |
+
credits += amt
|
| 54 |
+
data.append([f"2024-07-{txn:02d}", f"DEPOSIT {txn}", "", money(amt), money(running)])
|
| 55 |
+
else:
|
| 56 |
+
amt = 40_000
|
| 57 |
+
running -= amt
|
| 58 |
+
debits += amt
|
| 59 |
+
data.append([f"2024-07-{txn:02d}", f"PAYMENT {txn}", money(amt), "", money(running)])
|
| 60 |
+
|
| 61 |
+
t = Table(data, repeatRows=1)
|
| 62 |
+
t.setStyle(TableStyle([
|
| 63 |
+
("GRID", (0, 0), (-1, -1), 0.5, colors.grey),
|
| 64 |
+
("BACKGROUND", (0, 0), (-1, 0), colors.lightgrey),
|
| 65 |
+
("FONTSIZE", (0, 0), (-1, -1), 8),
|
| 66 |
+
]))
|
| 67 |
+
story.append(t)
|
| 68 |
+
|
| 69 |
+
if p == pages - 1:
|
| 70 |
+
story.append(Spacer(1, 8))
|
| 71 |
+
story.append(Paragraph(f"Closing Balance: {money(running)}", styles["Normal"]))
|
| 72 |
+
story.append(PageBreak())
|
| 73 |
+
|
| 74 |
+
SimpleDocTemplate(str(OUT), pagesize=A4).build(story)
|
| 75 |
+
print(f"wrote {OUT}")
|
| 76 |
+
print(f"{txn} txns across {pages} pages — ONE logical statement")
|
| 77 |
+
print(f"opening {money(opening)} + credits {money(credits)} - debits {money(debits)} = closing {money(running)}")
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
if __name__ == "__main__":
|
| 81 |
+
main()
|
tests/generators/make_real_style_pdf.py
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
make_real_style_pdf.py — faithful reproduction of the user's real-style US bank
|
| 3 |
+
statement (First National Bank mock), block 1 (30 txns, 2 pages).
|
| 4 |
+
|
| 5 |
+
Reproduces the REAL-WORLD structural features that synthetic tests lacked:
|
| 6 |
+
- account info as a label/value TABLE (no "Label:" colon lines)
|
| 7 |
+
- balances in a Beginning/Ending SUMMARY TABLE (not "Opening Balance: $X")
|
| 8 |
+
- a "Reference" column between Description and Debit
|
| 9 |
+
- gridded, multi-page transaction table with repeated header
|
| 10 |
+
"""
|
| 11 |
+
from __future__ import annotations
|
| 12 |
+
from pathlib import Path
|
| 13 |
+
from reportlab.lib import colors
|
| 14 |
+
from reportlab.lib.pagesizes import LETTER
|
| 15 |
+
from reportlab.lib.styles import getSampleStyleSheet
|
| 16 |
+
from reportlab.lib.units import mm
|
| 17 |
+
from reportlab.platypus import SimpleDocTemplate, Paragraph, Spacer, Table, TableStyle
|
| 18 |
+
|
| 19 |
+
HERE = Path(__file__).resolve().parent
|
| 20 |
+
OUT = HERE / "real_style_statement.pdf"
|
| 21 |
+
|
| 22 |
+
# (date, description, reference, debit, credit, balance) — transcribed from the real PDF
|
| 23 |
+
TXNS = [
|
| 24 |
+
("04/01/26", "POS PURCHASE MERCHANT 1", "REF10001", "37.35", "", "25,962.65"),
|
| 25 |
+
("04/02/26", "POS PURCHASE MERCHANT 2", "REF10002", "49.70", "", "25,912.95"),
|
| 26 |
+
("04/03/26", "ACH Payroll Deposit", "REF10003", "", "1,821.00", "27,733.95"),
|
| 27 |
+
("04/04/26", "POS PURCHASE MERCHANT 4", "REF10004", "74.40", "", "27,659.55"),
|
| 28 |
+
("04/05/26", "POS PURCHASE MERCHANT 5", "REF10005", "86.75", "", "27,572.80"),
|
| 29 |
+
("04/06/26", "ACH Payroll Deposit", "REF10006", "", "1,842.00", "29,414.80"),
|
| 30 |
+
("04/07/26", "POS PURCHASE MERCHANT 7", "REF10007", "111.45", "", "29,303.35"),
|
| 31 |
+
("04/08/26", "POS PURCHASE MERCHANT 8", "REF10008", "123.80", "", "29,179.55"),
|
| 32 |
+
("04/09/26", "ACH Payroll Deposit", "REF10009", "", "1,863.00", "31,042.55"),
|
| 33 |
+
("04/10/26", "POS PURCHASE MERCHANT 10", "REF10010", "148.50", "", "30,894.05"),
|
| 34 |
+
("04/11/26", "POS PURCHASE MERCHANT 11", "REF10011", "160.85", "", "30,733.20"),
|
| 35 |
+
("04/12/26", "ACH Payroll Deposit", "REF10012", "", "1,884.00", "32,617.20"),
|
| 36 |
+
("04/13/26", "POS PURCHASE MERCHANT 13", "REF10013", "185.55", "", "32,431.65"),
|
| 37 |
+
("04/14/26", "POS PURCHASE MERCHANT 14", "REF10014", "197.90", "", "32,233.75"),
|
| 38 |
+
("04/15/26", "ACH Payroll Deposit", "REF10015", "", "1,905.00", "34,138.75"),
|
| 39 |
+
("04/16/26", "POS PURCHASE MERCHANT 16", "REF10016", "222.60", "", "33,916.15"),
|
| 40 |
+
("04/17/26", "POS PURCHASE MERCHANT 17", "REF10017", "234.95", "", "33,681.20"),
|
| 41 |
+
("04/18/26", "ACH Payroll Deposit", "REF10018", "", "1,926.00", "35,607.20"),
|
| 42 |
+
("04/19/26", "POS PURCHASE MERCHANT 19", "REF10019", "259.65", "", "35,347.55"),
|
| 43 |
+
("04/20/26", "POS PURCHASE MERCHANT 20", "REF10020", "272.00", "", "35,075.55"),
|
| 44 |
+
("04/21/26", "ACH Payroll Deposit", "REF10021", "", "1,947.00", "37,022.55"),
|
| 45 |
+
("04/22/26", "POS PURCHASE MERCHANT 22", "REF10022", "296.70", "", "36,725.85"),
|
| 46 |
+
("04/23/26", "POS PURCHASE MERCHANT 23", "REF10023", "309.05", "", "36,416.80"),
|
| 47 |
+
("04/24/26", "ACH Payroll Deposit", "REF10024", "", "1,968.00", "38,384.80"),
|
| 48 |
+
("04/25/26", "POS PURCHASE MERCHANT 25", "REF10025", "333.75", "", "38,051.05"),
|
| 49 |
+
("04/26/26", "POS PURCHASE MERCHANT 26", "REF10026", "346.10", "", "37,704.95"),
|
| 50 |
+
("04/27/26", "ACH Payroll Deposit", "REF10027", "", "1,989.00", "39,693.95"),
|
| 51 |
+
("04/28/26", "POS PURCHASE MERCHANT 28", "REF10028", "370.80", "", "39,323.15"),
|
| 52 |
+
("04/29/26", "POS PURCHASE MERCHANT 29", "REF10029", "383.15", "", "38,940.00"),
|
| 53 |
+
("04/30/26", "ACH Payroll Deposit", "REF10030", "", "2,010.00", "40,950.00"),
|
| 54 |
+
]
|
| 55 |
+
|
| 56 |
+
styles = getSampleStyleSheet()
|
| 57 |
+
doc = SimpleDocTemplate(str(OUT), pagesize=LETTER, topMargin=14 * mm, bottomMargin=14 * mm,
|
| 58 |
+
leftMargin=14 * mm, rightMargin=14 * mm)
|
| 59 |
+
story = [
|
| 60 |
+
Paragraph("<b>FIRST NATIONAL BANK</b>", styles["Title"]),
|
| 61 |
+
Paragraph("123 Market Street, New York, NY 10001", styles["Normal"]),
|
| 62 |
+
Paragraph("MOCK STATEMENT – FICTIONAL DATA FOR SOFTWARE TESTING ONLY", styles["Normal"]),
|
| 63 |
+
Spacer(1, 8),
|
| 64 |
+
]
|
| 65 |
+
|
| 66 |
+
acct = Table([["Account Holder", "JOHN SAMPLE"],
|
| 67 |
+
["Account Type", "Checking Account"],
|
| 68 |
+
["Account Number", "XXXXXX4321"],
|
| 69 |
+
["Statement Period", "04/01/2026 - 04/30/2026"]], colWidths=[45 * mm, 90 * mm])
|
| 70 |
+
acct.setStyle(TableStyle([("GRID", (0, 0), (-1, -1), 0.5, colors.grey), ("FONTSIZE", (0, 0), (-1, -1), 9)]))
|
| 71 |
+
story += [acct, Spacer(1, 8)]
|
| 72 |
+
|
| 73 |
+
summary = Table([["Beginning Balance", "Deposits", "Withdrawals", "Ending Balance"],
|
| 74 |
+
["$24,580.12", "$12,440.00", "$8,915.45", "$28,104.67"]],
|
| 75 |
+
colWidths=[40 * mm, 35 * mm, 35 * mm, 40 * mm])
|
| 76 |
+
summary.setStyle(TableStyle([("GRID", (0, 0), (-1, -1), 0.5, colors.grey), ("FONTSIZE", (0, 0), (-1, -1), 9)]))
|
| 77 |
+
story += [summary, Spacer(1, 10)]
|
| 78 |
+
|
| 79 |
+
rows = [["Date", "Description", "Reference", "Debit", "Credit", "Balance"]] + [list(t) for t in TXNS]
|
| 80 |
+
tbl = Table(rows, colWidths=[20 * mm, 62 * mm, 26 * mm, 22 * mm, 22 * mm, 26 * mm], repeatRows=1)
|
| 81 |
+
tbl.setStyle(TableStyle([
|
| 82 |
+
("GRID", (0, 0), (-1, -1), 0.4, colors.grey),
|
| 83 |
+
("BACKGROUND", (0, 0), (-1, 0), colors.HexColor("#e6e6e6")),
|
| 84 |
+
("FONTSIZE", (0, 0), (-1, -1), 8),
|
| 85 |
+
("ALIGN", (3, 0), (-1, -1), "RIGHT"),
|
| 86 |
+
]))
|
| 87 |
+
story.append(tbl)
|
| 88 |
+
doc.build(story)
|
| 89 |
+
print("Wrote", OUT)
|
tests/generators/make_sample_pdf.py
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
make_sample_pdf.py — build a realistic bank-statement PDF from the sample xlsx.
|
| 3 |
+
|
| 4 |
+
This gives us a *test input* (a text-based PDF that looks like a real statement)
|
| 5 |
+
so we can prove the Python-first converter works end-to-end.
|
| 6 |
+
|
| 7 |
+
It reads the transactions out of Sample-CSV-Bank-Statement.xlsx, sorts them
|
| 8 |
+
chronologically, recomputes a clean running balance from a $0.00 opening, and
|
| 9 |
+
renders a header + a ruled transaction table.
|
| 10 |
+
"""
|
| 11 |
+
from __future__ import annotations
|
| 12 |
+
|
| 13 |
+
import datetime as dt
|
| 14 |
+
from pathlib import Path
|
| 15 |
+
|
| 16 |
+
import openpyxl
|
| 17 |
+
from reportlab.lib import colors
|
| 18 |
+
from reportlab.lib.pagesizes import A4
|
| 19 |
+
from reportlab.lib.styles import getSampleStyleSheet
|
| 20 |
+
from reportlab.lib.units import mm
|
| 21 |
+
from reportlab.platypus import (
|
| 22 |
+
SimpleDocTemplate, Paragraph, Spacer, Table, TableStyle,
|
| 23 |
+
)
|
| 24 |
+
|
| 25 |
+
HERE = Path(__file__).resolve().parent
|
| 26 |
+
XLSX = HERE.parent / "Sample-CSV-Bank-Statement.xlsx"
|
| 27 |
+
OUT_PDF = HERE / "sample_statement.pdf"
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def _to_float(v):
|
| 31 |
+
if v is None or v == "":
|
| 32 |
+
return None
|
| 33 |
+
try:
|
| 34 |
+
return float(str(v).replace(",", "").replace("$", "").strip())
|
| 35 |
+
except ValueError:
|
| 36 |
+
return None
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def load_transactions(xlsx_path: Path):
|
| 40 |
+
wb = openpyxl.load_workbook(xlsx_path, data_only=True)
|
| 41 |
+
ws = wb.active
|
| 42 |
+
rows = list(ws.iter_rows(values_only=True))
|
| 43 |
+
|
| 44 |
+
# find the header row (the one containing "Date")
|
| 45 |
+
header_idx = next(
|
| 46 |
+
i for i, r in enumerate(rows)
|
| 47 |
+
if r and any(str(c).strip().lower() == "date" for c in r if c is not None)
|
| 48 |
+
)
|
| 49 |
+
header = [str(c).strip().lower() if c is not None else "" for c in rows[header_idx]]
|
| 50 |
+
col = {name: header.index(name) for name in ("date", "description", "debit", "credit") if name in header}
|
| 51 |
+
|
| 52 |
+
txns = []
|
| 53 |
+
for r in rows[header_idx + 1:]:
|
| 54 |
+
if not r or r[col["date"]] is None:
|
| 55 |
+
continue
|
| 56 |
+
d = r[col["date"]]
|
| 57 |
+
if isinstance(d, dt.datetime):
|
| 58 |
+
date = d.date()
|
| 59 |
+
else:
|
| 60 |
+
continue
|
| 61 |
+
txns.append({
|
| 62 |
+
"date": date,
|
| 63 |
+
"description": str(r[col["description"]] or "").strip(),
|
| 64 |
+
"debit": _to_float(r[col["debit"]]),
|
| 65 |
+
"credit": _to_float(r[col["credit"]]),
|
| 66 |
+
})
|
| 67 |
+
txns.sort(key=lambda t: t["date"])
|
| 68 |
+
return txns
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
def build_pdf(txns, out_path: Path, opening: float = 0.00):
|
| 72 |
+
styles = getSampleStyleSheet()
|
| 73 |
+
doc = SimpleDocTemplate(str(out_path), pagesize=A4,
|
| 74 |
+
topMargin=18 * mm, bottomMargin=18 * mm,
|
| 75 |
+
leftMargin=16 * mm, rightMargin=16 * mm)
|
| 76 |
+
|
| 77 |
+
# recompute a clean running balance so the statement reconciles
|
| 78 |
+
running = opening
|
| 79 |
+
table_rows = [["Date", "Description", "Debit", "Credit", "Balance"]]
|
| 80 |
+
for t in txns:
|
| 81 |
+
running += (t["credit"] or 0.0) - (t["debit"] or 0.0)
|
| 82 |
+
table_rows.append([
|
| 83 |
+
t["date"].strftime("%d/%m/%Y"),
|
| 84 |
+
t["description"],
|
| 85 |
+
f"${t['debit']:,.2f}" if t["debit"] else "",
|
| 86 |
+
f"${t['credit']:,.2f}" if t["credit"] else "",
|
| 87 |
+
f"${running:,.2f} CR",
|
| 88 |
+
])
|
| 89 |
+
closing = running
|
| 90 |
+
period = f"{txns[0]['date'].strftime('%d/%m/%Y')} to {txns[-1]['date'].strftime('%d/%m/%Y')}"
|
| 91 |
+
|
| 92 |
+
story = [
|
| 93 |
+
Paragraph("<b>Macquarie Cash Management Account</b>", styles["Title"]),
|
| 94 |
+
Spacer(1, 6),
|
| 95 |
+
Paragraph("Account: 123456789 - CASH MANAGEMENT ACCOUNT", styles["Normal"]),
|
| 96 |
+
Paragraph("Account Holder: ABC Super Fund", styles["Normal"]),
|
| 97 |
+
Paragraph(f"Statement Period: {period}", styles["Normal"]),
|
| 98 |
+
Paragraph(f"Opening Balance: ${opening:,.2f}", styles["Normal"]),
|
| 99 |
+
Paragraph(f"Closing Balance: ${closing:,.2f}", styles["Normal"]),
|
| 100 |
+
Spacer(1, 12),
|
| 101 |
+
]
|
| 102 |
+
|
| 103 |
+
tbl = Table(table_rows, colWidths=[24 * mm, 70 * mm, 24 * mm, 24 * mm, 28 * mm])
|
| 104 |
+
tbl.setStyle(TableStyle([
|
| 105 |
+
("GRID", (0, 0), (-1, -1), 0.5, colors.grey),
|
| 106 |
+
("BACKGROUND", (0, 0), (-1, 0), colors.HexColor("#1f3a5f")),
|
| 107 |
+
("TEXTCOLOR", (0, 0), (-1, 0), colors.white),
|
| 108 |
+
("FONTNAME", (0, 0), (-1, 0), "Helvetica-Bold"),
|
| 109 |
+
("FONTSIZE", (0, 0), (-1, -1), 8),
|
| 110 |
+
("ALIGN", (2, 0), (-1, -1), "RIGHT"),
|
| 111 |
+
("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
|
| 112 |
+
]))
|
| 113 |
+
story.append(tbl)
|
| 114 |
+
doc.build(story)
|
| 115 |
+
return closing
|
| 116 |
+
|
| 117 |
+
|
| 118 |
+
if __name__ == "__main__":
|
| 119 |
+
txns = load_transactions(XLSX)
|
| 120 |
+
closing = build_pdf(txns, OUT_PDF)
|
| 121 |
+
print(f"Wrote {OUT_PDF}")
|
| 122 |
+
print(f"{len(txns)} transactions, closing balance ${closing:,.2f}")
|
tests/test_accuracy.py
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Accuracy harness — the release gate.
|
| 2 |
+
|
| 3 |
+
Runs the proven engine across the golden fixture corpus and asserts that every
|
| 4 |
+
statement (a) yields transactions and (b) RECONCILES (the engine's own integrity
|
| 5 |
+
check: running-balance and/or stated opening+credits-debits==closing).
|
| 6 |
+
|
| 7 |
+
Run directly for a readable table: python tests/test_accuracy.py
|
| 8 |
+
Run under pytest for the gate: pytest -q
|
| 9 |
+
"""
|
| 10 |
+
from __future__ import annotations
|
| 11 |
+
|
| 12 |
+
import sys
|
| 13 |
+
from pathlib import Path
|
| 14 |
+
|
| 15 |
+
ROOT = Path(__file__).resolve().parent.parent
|
| 16 |
+
sys.path.insert(0, str(ROOT))
|
| 17 |
+
|
| 18 |
+
from app.engine.converter import BankStatementConverter # noqa: E402
|
| 19 |
+
|
| 20 |
+
FIXTURES = ROOT / "tests" / "fixtures"
|
| 21 |
+
|
| 22 |
+
# The golden corpus: (fixture, expected number of statements). Every one is
|
| 23 |
+
# expected to extract, reconcile, AND section into the right number of statements
|
| 24 |
+
# (guards against over-/under-sectioning — e.g. a multi-page statement that wrongly
|
| 25 |
+
# splits one-per-page).
|
| 26 |
+
CASES = [
|
| 27 |
+
("sample_statement.pdf", 1),
|
| 28 |
+
("complex_statement.pdf", 1),
|
| 29 |
+
("real_style_statement.pdf", 1),
|
| 30 |
+
("two_block.pdf", 2),
|
| 31 |
+
("multipage_bf.pdf", 1), # multi-page, repeated account + "brought forward" headers
|
| 32 |
+
("hard/t1_no_gridlines.pdf", 1),
|
| 33 |
+
("hard/t2_scanned.pdf", 1),
|
| 34 |
+
("hard/t3_renamed_headers.pdf", 1),
|
| 35 |
+
("hard/t4_signed_amount.pdf", 1),
|
| 36 |
+
("hard/t5_european_format.pdf", 1),
|
| 37 |
+
("hard/t6_multi_account.pdf", 2),
|
| 38 |
+
("hard/t7_diff_balance_labels.pdf", 1),
|
| 39 |
+
("hard/t8_text_dates.pdf", 1),
|
| 40 |
+
]
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
def _run(name: str):
|
| 44 |
+
statements = BankStatementConverter(FIXTURES / name).extract()
|
| 45 |
+
n_txns = sum(len(s.transactions) for s in statements)
|
| 46 |
+
all_reconcile = bool(statements) and all(s.reconciles is True for s in statements)
|
| 47 |
+
return statements, n_txns, all_reconcile
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def test_corpus_reconciles():
|
| 51 |
+
"""Hard gate: every fixture extracts, reconciles, and sections correctly."""
|
| 52 |
+
failures = []
|
| 53 |
+
for name, expect in CASES:
|
| 54 |
+
statements, n_txns, ok = _run(name)
|
| 55 |
+
if not statements or n_txns == 0:
|
| 56 |
+
failures.append(f"{name}: no transactions extracted")
|
| 57 |
+
continue
|
| 58 |
+
if len(statements) != expect:
|
| 59 |
+
failures.append(f"{name}: expected {expect} statement(s), got {len(statements)}")
|
| 60 |
+
if not ok:
|
| 61 |
+
detail = " ; ".join(f"{s.reconciles}:{s.reconciliation_detail}" for s in statements)
|
| 62 |
+
failures.append(f"{name}: did not reconcile -> {detail}")
|
| 63 |
+
assert not failures, "Accuracy regressions:\n" + "\n".join(failures)
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
def _main() -> int:
|
| 67 |
+
flag = {True: "PASS", False: "FAIL", None: "N/A "}
|
| 68 |
+
passed = 0
|
| 69 |
+
print("=" * 80)
|
| 70 |
+
print(f"{'fixture':<34} {'stmts':>5} {'exp':>4} {'txns':>5} reconcile")
|
| 71 |
+
print("-" * 80)
|
| 72 |
+
for name, expect in CASES:
|
| 73 |
+
statements, n_txns, ok = _run(name)
|
| 74 |
+
rec = " / ".join(flag[s.reconciles] for s in statements) or "—"
|
| 75 |
+
good = bool(statements) and n_txns > 0 and ok and len(statements) == expect
|
| 76 |
+
passed += 1 if good else 0
|
| 77 |
+
mark = "" if len(statements) == expect else " <-- SECTION MISMATCH"
|
| 78 |
+
print(f"{name:<34} {len(statements):>5} {expect:>4} {n_txns:>5} {rec}{mark}")
|
| 79 |
+
print("=" * 80)
|
| 80 |
+
print(f"RESULT: {passed}/{len(CASES)} fixtures extracted + reconciled + sectioned correctly")
|
| 81 |
+
return 0 if passed == len(CASES) else 1
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
if __name__ == "__main__":
|
| 85 |
+
raise SystemExit(_main())
|