pinion-pdf 1.0.0: rebuild a CSV from a spreadsheet printed to PDF
Derives page layout from the file itself (batch size, column edges, row grid, labelling font), places text by start-x to survive cell overflow, detects blackout redactions including filled-path bars and single-character bars, and self-verifies via Excel's printed row-number chain. Output byte-identical to four independently written implementations across a 1,048,576-row reference file. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012bQ28wRBiQYcSR9FzV9U7c
This commit is contained in:
commit
3902ce043e
11 files changed
+695
No files matched your search
@@ -0,0 +1,88 @@
|
||||
"""Run it, then check our own working.
|
||||
|
||||
The checks are the point: pinion refuses to hand you plausible-looking
|
||||
output without also handing you the evidence it's right. The strongest one
|
||||
is the row-number chain -- Excel's own printed numbers must count 1..N with
|
||||
no gaps or repeats, which any method that drops or invents rows fails
|
||||
immediately.
|
||||
"""
|
||||
|
||||
import sys
|
||||
import csv
|
||||
import argparse
|
||||
|
||||
try:
|
||||
import pymupdf # noqa: F401 (probed here for a friendly error)
|
||||
except ImportError:
|
||||
sys.exit("Missing dependency. Run: pip install pymupdf")
|
||||
|
||||
from .measure import measure
|
||||
from .convert import convert
|
||||
|
||||
|
||||
def report(label, value, ok=None, bad=""):
|
||||
"""One line of the results table. ok=None means purely informational."""
|
||||
tail = "" if ok is None else (" ok" if ok else f" <-- {bad}")
|
||||
print(f" {label + ' ':.<28} {value}{tail}")
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(
|
||||
prog="pinion",
|
||||
description="Rebuild a CSV from a spreadsheet that was printed to PDF.")
|
||||
ap.add_argument("pdf")
|
||||
ap.add_argument("out_csv")
|
||||
ap.add_argument("--limit", type=int, default=0,
|
||||
help="only convert the first N batches, for a trial run")
|
||||
args = ap.parse_args()
|
||||
|
||||
M = measure(args.pdf)
|
||||
n_batches = min(args.limit, M["n_batches"]) if args.limit else M["n_batches"]
|
||||
|
||||
print("CONVERTING (takes a few minutes)")
|
||||
print("-" * 62)
|
||||
doc = pymupdf.open(args.pdf)
|
||||
with open(args.out_csv, "w", newline="", encoding="utf-8") as fh:
|
||||
stats = convert(doc, M, csv.writer(fh), n_batches)
|
||||
doc.close()
|
||||
|
||||
expected = n_batches * M["rows_per_page"] - 1
|
||||
misplaced = stats["text_no_column"] + stats["text_no_row"]
|
||||
tally = {}
|
||||
for _row, letter, _width in stats["blackouts"]:
|
||||
tally[letter] = tally.get(letter, 0) + 1
|
||||
|
||||
print(f"\nWritten to {args.out_csv}\n")
|
||||
print("CHECKING OUR OWN WORKING")
|
||||
print("-" * 62)
|
||||
report("rows written", f"{stats['n_rows']:,}")
|
||||
report("rows expected", f"{expected:,}", stats["n_rows"] == expected, "MISMATCH")
|
||||
report("row numbers 1..N unbroken", "yes" if stats["chain_ok"] else "NO",
|
||||
stats["chain_ok"], "GAPS OR DUPLICATES")
|
||||
report("text we could not place",
|
||||
f"{stats['text_no_column']:,} by column, {stats['text_no_row']:,} by row",
|
||||
misplaced == 0, "SOME TEXT LOST")
|
||||
report("bars we could not place", f"{stats['bars_no_row']:,}",
|
||||
stats["bars_no_row"] == 0, "REDACTIONS LOST")
|
||||
report("printed numbers disagreed", f"{stats['gutter_disagreed']:,}",
|
||||
stats["gutter_disagreed"] == 0, "LAYOUT WRONG")
|
||||
report("row numbers hidden by bars",
|
||||
f"{stats['gutter_unreadable']:,} (placed by spacing instead)")
|
||||
report("cells drawn in pieces",
|
||||
f"{stats['pieces']:,} (rejoined; nothing lost)")
|
||||
print()
|
||||
report("blackout bars found", f"{len(stats['blackouts']):,}")
|
||||
report("rows affected", f"{len(set(b[0] for b in stats['blackouts'])):,}")
|
||||
report("which columns", tally or "none")
|
||||
if stats["readable"]:
|
||||
report("TEXT READABLE UNDER BARS", f"{len(stats['readable']):,}",
|
||||
False, "REDACTION IS COSMETIC, TEXT RECOVERABLE")
|
||||
for row, letter, text in stats["readable"][:10]:
|
||||
print(f" row {row:,} column {letter}: {text!r}")
|
||||
else:
|
||||
report("text readable under bars", "none (deleted, not merely covered)")
|
||||
|
||||
if n_batches * M["rows_per_page"] == 1048576:
|
||||
print("\n WARNING - exactly 1,048,576 rows: Excel's hard ceiling.")
|
||||
print(" The source was almost certainly truncated there, so every")
|
||||
print(" total from this file is a floor, not a full count.")
|
||||
Reference in new issue
Block a user