rudrahf Claude Opus 4.8 (1M context) commited on
Commit
0ebef7d
·
0 Parent(s):

Initial commit — Export Bank Statement extractor (FastAPI)

Browse files

Proven 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 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())