v1.1.0: headerless mode for audits printed without row/column headings
Sniffs the format on page 1: printed row numbers -> gridded mode (unchanged); UUID-keyed rows -> new headerless mode, which anchors rows on per-record UUIDs and takes the column map from the page-1 header labels. Handles fused cells (ID+Name+Org+networks), mid-value line wraps, two-timestamp Time Frame ranges, wrapped header labels, and per-export schema drift. Validated against three monthly JC PD audits: 120/120 cross-engine spot checks, 0 anomalies, field-identical to the standalone converter it replaces. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012bQ28wRBiQYcSR9FzV9U7c
This commit is contained in:
1 parent
74b06be742
commit
c9c90920cc
6 files changed
+394
-65
No files matched your search
+60
-1
@@ -7,8 +7,10 @@ try:
|
||||
except ImportError:
|
||||
sys.exit("Missing dependency. Run: pip install pymupdf")
|
||||
|
||||
from .page import all_runs, run_start, run_text
|
||||
from .measure import measure
|
||||
from .convert import convert
|
||||
from .headerless import looks_headerless, convert_headerless
|
||||
|
||||
|
||||
def report(label, value, ok=None, bad=""):
|
||||
@@ -24,9 +26,30 @@ def main():
|
||||
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")
|
||||
help="only convert the first N batches (gridded) or "
|
||||
"pages (headerless), for a trial run")
|
||||
args = ap.parse_args()
|
||||
|
||||
# Which of the two known print formats is this? A gridded Excel print
|
||||
# puts its row numbers leftmost on the page; the headerless audit
|
||||
# variant has no headings at all but keys every row with a UUID.
|
||||
doc = pymupdf.open(args.pdf)
|
||||
runs0 = all_runs(doc[0])
|
||||
if not runs0:
|
||||
sys.exit("Page 1 has no text at all. Is this a scan?")
|
||||
leftmost = min(runs0, key=lambda r: run_start(r)[0])
|
||||
if run_text(leftmost).strip().isdigit():
|
||||
doc.close()
|
||||
run_gridded(args)
|
||||
elif looks_headerless(doc[0]):
|
||||
run_headerless(doc, args)
|
||||
else:
|
||||
sys.exit("Unrecognised layout: no printed row numbers (gridded "
|
||||
"Excel print) and no UUID-keyed rows (headerless audit "
|
||||
"print). pinion doesn't know this format yet.")
|
||||
|
||||
|
||||
def run_gridded(args):
|
||||
M = measure(args.pdf)
|
||||
n_batches = min(args.limit, M["n_batches"]) if args.limit else M["n_batches"]
|
||||
|
||||
@@ -76,3 +99,39 @@ def main():
|
||||
print("\n WARNING: exactly 1,048,576 rows. That's an Excel magic number")
|
||||
print(" The source was almost certainly truncated.")
|
||||
print(" Go tell the source they owe you more data.")
|
||||
|
||||
|
||||
def run_headerless(doc, args):
|
||||
n_pages = min(args.limit, doc.page_count) if args.limit else doc.page_count
|
||||
print("HEADERLESS AUDIT PRINT (no row/column headings; anchoring on")
|
||||
print("the page-1 header labels and per-row UUIDs)")
|
||||
print("-" * 62)
|
||||
with open(args.out_csv, "w", newline="", encoding="utf-8") as fh:
|
||||
r = convert_headerless(doc, csv.writer(fh), n_pages)
|
||||
doc.close()
|
||||
s = r["stats"]
|
||||
|
||||
print(f"\nWritten to {args.out_csv}\n")
|
||||
print("-" * 62)
|
||||
report("rows written", f"{r['n_rows']:,}")
|
||||
report("columns in this export", f"{len(r['names'])}/13",
|
||||
not r["missing"],
|
||||
f"absent from the PDF itself: {r['missing']}")
|
||||
report("org name (derived)", repr(r["org"]))
|
||||
report("time-frame ranges",
|
||||
f"{r['tf_ok']:,} valid pairs, {r['tf_single']} single",
|
||||
r["tf_bad"] == 0, f"{r['tf_bad']} MALFORMED")
|
||||
report("rows with a bad uuid", s["rows_bad_uuid"],
|
||||
s["rows_bad_uuid"] == 0, "ROWS DROPPED")
|
||||
report("unplaced runs", s["runs_no_column"] + s["runs_below_last_anchor"],
|
||||
s["runs_no_column"] + s["runs_below_last_anchor"] == 0,
|
||||
"SOME TEXT LOST")
|
||||
report("strays in name/org windows", s["strays_in_name_org_windows"],
|
||||
s["strays_in_name_org_windows"] == 0, "COLUMN MAPPED WRONG")
|
||||
report("fused SearchTime split", s["searchtime_splits"])
|
||||
report("blackout bars found", len(r["bars"]))
|
||||
if r["missing"]:
|
||||
print(f"\n NOTE: this export has no {', '.join(r['missing'])} "
|
||||
"column(s).")
|
||||
print(" That data was never in the PDF; the CSV column is left "
|
||||
"empty.")
|
||||
Reference in new issue
Block a user