25592fa6c2
ci / test (push) Successful in 6m14s
History-free snapshot of the private development repository at 77a82eb. Code, docs and the public catalogue only: no plant or job knowledge layers, no client, job or producer names (tools/scrub.py clean against the widened term list; a second word-level grep clean). The intro deck is left out because its slide images cannot be checked for job drawings.
227 lines
8.4 KiB
Python
227 lines
8.4 KiB
Python
# -*- 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())
|