# -*- coding: utf-8 -*- """Revision delta for precast shop tickets -- CLI. python tools/ticket_delta.py OLD NEW [--crops DIR] [--json OUT.json] OLD and NEW may each be a single PDF or a folder of them. Folders are paired by mark, because the two issues of a real set were named differently (old by mark, new by job-control-mark) and the mark is the only identifier both carry. Needs pymupdf on the path. It is not a dependency of the extension -- the Revit side runs IronPython and cannot import it -- so this script is the only thing in the repo that touches a PDF. """ from __future__ import print_function import argparse import json import os import sys import types _HERE = os.path.dirname(os.path.abspath(__file__)) _LIB = os.path.join(_HERE, '..', 'WillTools.extension', 'lib') sys.path.insert(0, _LIB) # Same trick the test conftest uses, and for the same reason: vibe/__init__.py # imports the Revit API at module scope, which is right inside Revit and fatal # on host CPython. Register a bare `vibe` package pointed at the directory so # the Revit-free submodules import normally and __init__.py never runs. if 'vibe' not in sys.modules: _stub = types.ModuleType('vibe') _stub.__path__ = [os.path.join(_LIB, 'vibe')] sys.modules['vibe'] = _stub from vibe import ticketdelta # noqa: E402 def _load_extract(): try: from vibe import ticketextract import pymupdf # noqa: F401 except ImportError as exc: raise SystemExit( 'ticket_delta needs pymupdf on PYTHONPATH (import name pymupdf, ' 'not fitz). %s' % exc) return ticketextract SEV_MARK = { ticketdelta.MATERIAL: '!!', ticketdelta.UNCERTAIN: '??', ticketdelta.ADVISORY: ' *', ticketdelta.HOUSEKEEP: ' ', } def _pdfs(path): if os.path.isdir(path): return sorted(f for f in os.listdir(path) if f.lower().endswith('.pdf')) return [os.path.basename(path)] def _dir(path): return path if os.path.isdir(path) else os.path.dirname(os.path.abspath(path)) def render_crop(pdf, page, x, y, out_png, dpi=300, pad=90): """Render the neighbourhood of a finding so a human can read the number. This exists because the text layer is not the sheet. Four independent text-layer failure modes have been measured on this project's sources, and each one produces a confident wrong number rather than an error. Every reported change ships with the picture it was read from; a reviewer who disagrees with the tool can see immediately which of them is wrong. """ import pymupdf doc = pymupdf.open(pdf) pg = doc[page] clip = pymupdf.Rect(max(0, x - pad), max(0, y - pad), min(pg.rect.x1, x + pad), min(pg.rect.y1, y + pad)) pix = pg.get_pixmap(dpi=dpi, clip=clip) pix.save(out_png) doc.close() return out_png def main(argv=None): ap = argparse.ArgumentParser(description=__doc__) ap.add_argument('old') ap.add_argument('new') ap.add_argument('--crops', help='directory to render a PNG per material ' 'finding into') ap.add_argument('--json', dest='json_out') ap.add_argument('--all', action='store_true', help='print housekeeping findings too') args = ap.parse_args(argv) tx = _load_extract() old_dir, new_dir = _dir(args.old), _dir(args.new) plan = ticketdelta.pair_sets(_pdfs(args.old), _pdfs(args.new)) print('=' * 78) print('REVISION DELTA') print(' old issue: %s (%d file(s))' % (args.old, plan['naming']['old_count'])) print(' new issue: %s (%d file(s))' % (args.new, plan['naming']['new_count'])) if plan['naming']['changed']: print('') print(' !! THE FILE NAMING CHANGED BETWEEN ISSUES.') print(' old files carry a control number on %d of %d;' ' new on %d of %d.' % (plan['naming']['old_has_control'], plan['naming']['old_count'], plan['naming']['new_has_control'], plan['naming']['new_count'])) print(' These two issues therefore CANNOT be paired by filename.') print(' Pairing below is on the mark, the only identifier both' ' namings carry.') if plan['naming']['fan_out']: print('') print(' !! ONE OLD FILE MAPS TO SEVERAL NEW ONES:') for m, n in sorted(plan['naming']['fan_out'].items()): print(' %s: 1 old file -> %d new files' % (m, n)) print(' One ticket per control number: several identical panels' ' now get') print(' several tickets. The comparison below runs against each' ' of them.') for f in plan['old_only']: print(' !! WITHDRAWN: %s is in the old issue and has no counterpart' ' in the new' % f) for f in plan['new_only']: print(' !! ADDED: %s is in the new issue and has no counterpart' ' in the old' % f) print('=' * 78) cache = {} def get(path): if path not in cache: cache[path] = tx.extract(path) return cache[path] results = [] for orec, nrecs in plan['pairs']: a = get(os.path.join(old_dir, orec['file'])) for nrec in nrecs: b = get(os.path.join(new_dir, nrec['file'])) fs = ticketdelta.compare(a, b, tx.parse_dim, tx.format_inches, tx.locate) results.append({'old': orec['file'], 'new': nrec['file'], 'mark': orec['mark'], 'control': nrec['control'], 'findings': fs}) for r in results: counts = ticketdelta.summarise(r['findings']) print('') print('-' * 78) print('%s %s -> %s' % (r['mark'], r['old'], r['new'])) print(' %d material, %d uncertain, %d advisory, %d housekeeping' % (counts[ticketdelta.MATERIAL], counts[ticketdelta.UNCERTAIN], counts[ticketdelta.ADVISORY], counts[ticketdelta.HOUSEKEEP])) shown = 0 for i, f in enumerate(r['findings']): if f['severity'] == ticketdelta.HOUSEKEEP and not args.all: continue shown += 1 print(' %s [%s] %s' % (SEV_MARK[f['severity']], f['kind'], f['where'])) print(' %s' % f['what']) if f['old'] is not None or f['new'] is not None: print(' %s -> %s' % (_short(f['old']), _short(f['new']))) if f['note']: print(' (%s)' % f['note']) if not shown: print(' nothing above housekeeping.') if args.crops: if not os.path.isdir(args.crops): os.makedirs(args.crops) n = 0 for r in results: for i, f in enumerate(r['findings']): if f['severity'] != ticketdelta.MATERIAL: continue for side, key, pdfdir, fname in ( ('old', 'loc_old', old_dir, r['old']), ('new', 'loc_new', new_dir, r['new'])): loc = f.get(key) if not loc: continue stem = os.path.splitext(fname)[0] out = os.path.join(args.crops, '%s_%02d_%s.png' % (stem, i, side)) try: render_crop(os.path.join(pdfdir, fname), loc['page'], loc['x'], loc['y'], out) f.setdefault('crops', {})[side] = out n += 1 except Exception as exc: print(' crop failed: %s' % exc) print('\n%d crop(s) written to %s' % (n, args.crops)) if args.json_out: with open(args.json_out, 'w') as fh: json.dump({'plan': {k: v for k, v in plan.items() if k != 'pairs'}, 'results': results}, fh, indent=1, default=str) print('\njson written to %s' % args.json_out) total_material = sum(ticketdelta.summarise(r['findings'])[ticketdelta.MATERIAL] for r in results) print('') print('=' * 78) print('%d pair(s) compared, %d material change(s) in total.' % (len(results), total_material)) return 0 def _short(v, n=110): if v is None: return '(none)' v = str(v) return v if len(v) <= n else v[:n - 3] + '...' if __name__ == '__main__': sys.exit(main())