#!/usr/bin/env python3 """Small synthetic edge-case exploration, using pdfplumber as the only extractor.""" from datetime import datetime, timezone from hashlib import sha256 from importlib.metadata import version import json from pathlib import Path import platform import pdfplumber import pypdfium2 from reportlab.lib.pagesizes import letter from reportlab.lib.utils import ImageReader from reportlab.pdfgen import canvas ROOT = Path(__file__).resolve().parent OUT = ROOT / "artifacts" OUT.mkdir(exist_ok=True) HEADER = ["Item", "Quantity", "Unit price"] FIRST = [HEADER, ["Alpha", "2", "10.00"], ["Beta", "5", "3.25"], ["Gamma", "1", "99.00"]] SECOND = [HEADER, ["Delta", "4", "8.50"], ["Epsilon", "3", "12.00"], ["Zeta", "6", "5.75"]] LINES = {"vertical_strategy": "lines", "horizontal_strategy": "lines"} TEXT = {"vertical_strategy": "text", "horizontal_strategy": "text"} def draw_table(pdf, cells, ruled=True): xs, top, height = [72, 282, 392, 540], 620, 38 if ruled: for x in xs: pdf.line(x, top - height * len(cells), x, top) for i in range(len(cells) + 1): y = top - i * height pdf.line(xs[0], y, xs[-1], y) for row, values in enumerate(cells): pdf.setFont("Helvetica-Bold" if row == 0 else "Helvetica", 11) for column, value in enumerate(values): pdf.drawString(xs[column] + 12, top - row * height - 24, value) def make_pdf(name, pages, ruled=True, rotated=False): path = OUT / name # ReportLab swaps the MediaBox sides for 90-degree page rotation. Supplying # the reversed canvas size keeps the original portrait content fully visible. page_size = (letter[1], letter[0]) if rotated else letter pdf = canvas.Canvas(str(path), pagesize=page_size, invariant=1, pageCompression=0) pdf.setTitle("Synthetic table edge-case fixture") pdf.setAuthor("Proof of Use synthetic examples") for index, cells in enumerate(pages): # Rotation is page metadata, rather than rasterization or corrected text. if rotated: pdf.setPageRotation(90) # Keep the borderless fixture table-only, so table-region detection is not assumed. draw_table(pdf, cells, ruled) pdf.showPage() pdf.save() return path def rasterize(source, target): document = pypdfium2.PdfDocument(str(source)) page = document[0] bitmap = page.render(scale=2) image = bitmap.to_pil() pdf = canvas.Canvas(str(target), pagesize=letter, invariant=1, pageCompression=0) pdf.setTitle("Synthetic image-only negative control") pdf.drawImage(ImageReader(image), 0, 0, width=letter[0], height=letter[1]) pdf.save() image.close() bitmap.close() page.close() document.close() def extract(path, settings): with pdfplumber.open(path) as document: return [{ "page": i + 1, "rotation": page.rotation, "width": page.width, "height": page.height, "character_count": len(page.chars), "image_count": len(page.images), "tables": page.extract_tables(table_settings=settings), } for i, page in enumerate(document.pages)] def row_cleanup(table): # Generic cleanup: strip cell whitespace and remove wholly blank rows. # No fixture labels, numeric values or table coordinates are used. return [[(cell or "").strip() for cell in row] for row in table if any((cell or "").strip() for cell in row)] def render_previews(fixtures): previews = ROOT / "previews" previews.mkdir(exist_ok=True) for fixture in fixtures: document = pypdfium2.PdfDocument(str(fixture)) for index in range(len(document)): page = document[index] bitmap = page.render(scale=1.25) image = bitmap.to_pil() image.save(previews / f"{fixture.stem}-page-{index + 1}.png") image.close() bitmap.close() page.close() document.close() def experiment(case_id, fixture, settings, expected_pages): observed = extract(fixture, settings) raw = [page["tables"] for page in observed] normalized = [[row_cleanup(table) for table in tables] for tables in raw] return { "case_id": case_id, "fixture": fixture.name, "settings": settings, "expected_tables_by_page": expected_pages, "observed_pages": observed, "raw_exact_match": raw == expected_pages, "normalized_exact_match": normalized == expected_pages, "normalization": "Strip cell whitespace and discard wholly blank rows; no reordering or inferred cells.", "normalized_tables_by_page": normalized, } def main(): digital = make_pdf("digital-ruled.pdf", [FIRST]) multipage = make_pdf("multipage-ruled.pdf", [FIRST, SECOND]) borderless = make_pdf("borderless-regular.pdf", [FIRST], ruled=False) rotated = make_pdf("rotated-90.pdf", [FIRST], rotated=True) scanned = OUT / "image-only.pdf" rasterize(digital, scanned) results = [ experiment("digital-ruled-default", digital, LINES, [[FIRST]]), experiment("multipage-ruled-default", multipage, LINES, [[FIRST], [SECOND]]), experiment("borderless-default-lines", borderless, LINES, [[FIRST]]), experiment("borderless-text-strategies", borderless, TEXT, [[FIRST]]), experiment("rotated-90-default", rotated, LINES, [[FIRST]]), experiment("image-only-without-ocr", scanned, LINES, [[FIRST]]), ] # This explicit post-processing is not an automatic pdfplumber feature. per_page = results[1]["normalized_tables_by_page"] stitched = [] for page_tables in per_page: for table in page_tables: stitched.extend(table[1:] if stitched and table and table[0] == stitched[0] else table) results[1]["explicit_stitching"] = { "algorithm": "Concatenate tables in page order, removing a repeated first row only when it exactly equals the first table's header.", "expected": FIRST + SECOND[1:], "actual": stitched, "exact_match": stitched == FIRST + SECOND[1:], "limitation": "Assumes these tables belong together; does not infer continuation or handle missing/split rows.", } fixtures = {} for path in [digital, multipage, borderless, rotated, scanned]: fixtures[path.name] = {"sha256": sha256(path.read_bytes()).hexdigest(), "bytes": path.stat().st_size} render_previews([digital, multipage, borderless, rotated, scanned]) report = { "kind": "synthetic_pdfplumber_edge_case_exploration", "executed_at_utc": datetime.now(timezone.utc).isoformat(timespec="seconds"), "environment": {"python": platform.python_version(), "os": platform.system(), "architecture": platform.machine(), "packages": {name: version(name) for name in ["pdfplumber", "reportlab", "pypdfium2"]}}, "extractor": "pdfplumber.Page.extract_tables", "fixtures": fixtures, "results": results, "limits": [ "Synthetic, regular English text and simple numeric cells; no customer documents.", "One fixture per edge case is not representative evidence or an accuracy estimate.", "Only pdfplumber is executed as an extractor; ReportLab generates and PDFium renders fixtures.", "No OCR pipeline/model was installed or run, and no model licenses were cleared.", "No network calls, uploads, speed or memory measurements.", "No production website, matcher dataset, ranking or public artifact is changed.", ], } (OUT / "results.json").write_text(json.dumps(report, indent=2) + "\n") summary = [{"case": case["case_id"], "raw_exact_match": case["raw_exact_match"], "normalized_exact_match": case["normalized_exact_match"], "tables_per_page": [len(page["tables"]) for page in case["observed_pages"]]} for case in results] print(json.dumps(summary, indent=2)) if __name__ == "__main__": main()