WillTools 2026-09-09 -- clean snapshot of the development repo for review
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.
This commit is contained in:
2026-09-09 08:35:15 -05:00
commit 25592fa6c2
406 changed files with 223435 additions and 0 deletions
@@ -0,0 +1,714 @@
# -*- coding: utf-8 -*-
"""Read one shop-ticket PDF into a structure a revision diff can reason about.
WHY THIS MODULE EXISTS
A ticket is not a page of prose. It is a title block, a bill of material, three
handling diagrams, a set of note blocks, and a drawing carrying dimension
chains and part callouts. Those are the things a precast engineer names when
he says what changed. A diff that works on lines of text says "line 47
differs"; a diff that works on these says "the dunnage diagram's near-end
inset went from 4'-9" to 5'-0"". Only the second one gets read.
So extraction is region-aware, and every region is found by an ANCHOR, never
by an absolute coordinate. Measured reason: WP199 puts its handling diagrams
at x=819 and WP219 puts them at x=910. A coordinate-keyed extractor would have
silently returned nothing for half the set and reported "no changes".
WHAT THIS MODULE REFUSES TO DO
It does not guess. Four PDF text-layer failure modes were measured on this
project's other source documents, and each one produces a CONFIDENT WRONG
NUMBER rather than an error:
1. stacked fractions collapsing ("1316"" for both 13/16" and 1 3/16")
2. cipher-shifted subset fonts (characters mapped 29 code points down)
3. stamps invisible to text search
4. retired text sitting underneath current text, returned first
A diff is uniquely exposed to these, because a misread on ONE SIDE ONLY
invents a change that never happened. So `hazards()` scans for all four and
the diff downgrades or drops anything they touch. Measured on this ticket set:
modes 1 and 2 are absent (fractions come through as "1/2", no non-ASCII byte
in 46,079 spans), mode 4 is present (345 exactly-colocated duplicate spans,
2 colocated pairs with DIFFERENT text). That measurement is what licenses
reading these particular numbers off the text layer; it is not a general
licence, which is why the scan runs on every file every time.
CPython only -- pymupdf is not available to IronPython, so the Revit-side code
must not import this. The comparison half lives in ticketdelta.py and is pure.
"""
import os
import re
# --------------------------------------------------------------------------
# dimension parsing
# --------------------------------------------------------------------------
# A ticket writes the same length two ways depending on which family drew it:
# the bed plan prints 1' - 0", the handling diagrams print 1'-0". Comparing the
# strings would report a change on every ticket. Compare the VALUE, show the
# RAW TEXT.
_DIM_RE = re.compile(
r'^(?:(\d+)\'\s*-?\s*)?' # feet
r'(?:(\d+)(?!/))?' # whole inches (not the numerator of a fraction)
r'\s*(?:(\d+)/(\d+))?' # fractional inches
r'"$'
)
def parse_dim(text):
"""Feet-and-inches string -> inches as a float, or None if not a dimension.
Accepts 22'-11", 22' - 11", 2' - 5 1/2", 11 1/2", 8", 3/4".
Returns None for anything else -- including things that merely contain a
dimension, such as (5) @ 1'-6" = 7'-6", which is a note, not a dimension.
"""
if not text:
return None
t = text.strip().replace(u'’', "'").replace(u'”', '"')
m = _DIM_RE.match(t)
if not m:
return None
ft, inch, num, den = m.groups()
if ft is None and inch is None and num is None:
return None
total = 0.0
if ft:
total += float(ft) * 12.0
if inch:
total += float(inch)
if num and den and float(den):
total += float(num) / float(den)
return total
def format_inches(v):
"""Inches -> a feet-and-inches string, for reporting derived totals only."""
if v is None:
return '?'
neg = v < 0
v = abs(v)
ft = int(v // 12)
rem = v - ft * 12
inch = int(rem)
frac = rem - inch
sixteenths = int(round(frac * 16))
if sixteenths == 16:
inch += 1
sixteenths = 0
out = ''
if ft:
out += "%d'-" % ft
out += '%d' % inch
if sixteenths:
n, d = sixteenths, 16
while n % 2 == 0:
n //= 2
d //= 2
out += ' %d/%d' % (n, d)
out += '"'
return ('-' if neg else '') + out
# --------------------------------------------------------------------------
# raw span reading
# --------------------------------------------------------------------------
def read_spans(path):
"""Every text span in the PDF as dicts, deduplicated.
Deduplication is not tidying. This set contains 345 spans that are drawn
twice at the same point -- a dimension double-placed in the source view.
Left in, a ticket that happens to double-place a different dimension in
the new issue reports a phantom "added" string.
"""
import pymupdf
doc = pymupdf.open(path)
spans = []
seen = set()
for pno, page in enumerate(doc):
for block in page.get_text('dict')['blocks']:
if block.get('type') != 0:
continue
for line in block['lines']:
d = line.get('dir', (1.0, 0.0))
vertical = abs(d[1]) > abs(d[0])
for sp in line['spans']:
text = sp['text'].strip()
if not text:
continue
bb = sp['bbox']
key = (pno, text, round(bb[0], 1), round(bb[1], 1))
if key in seen:
continue
seen.add(key)
spans.append({
'page': pno,
'text': text,
'x': bb[0], 'y': bb[1],
'x1': bb[2], 'y1': bb[3],
'size': round(sp['size'], 1),
'font': sp['font'],
'vertical': vertical,
'block': id(block),
})
npages = doc.page_count
doc.close()
return spans, npages
# --------------------------------------------------------------------------
# hazards -- the four measured text-layer failure modes
# --------------------------------------------------------------------------
_COLLAPSED_RE = re.compile(r'^\d{3,}"$')
# Sheet furniture: printed on every ticket, meaning nothing about the piece,
# and grouped into whatever text block happens to be near it. Left in, it
# makes a note look reworded because the page number moved.
SHEET_FURNITURE_RE = re.compile(r'^PAGE\s+\d+\s+OF\s+\d+$', re.I)
def hazards(spans, npages):
"""Text-layer conditions that make a value untrustworthy.
Returns a list of dicts {mode, detail, page, x, y, texts}. An empty list is
a measurement, not an assumption: it means all four scans ran and found
nothing on this file.
"""
found = []
# Mode 1 -- a stacked fraction that lost its slash reads as one long
# integer. 13/16" and 1 3/16" both collapse to 1316".
for s in spans:
if _COLLAPSED_RE.match(s['text']):
found.append({'mode': 'collapsed-fraction', 'page': s['page'],
'x': s['x'], 'y': s['y'], 'texts': [s['text']],
'detail': 'reads as a single integer; may be a '
'stacked fraction that lost its slash'})
# Mode 2 -- a cipher-shifted subset font puts digits on control characters.
for s in spans:
bad = [c for c in s['text'] if ord(c) < 32 or ord(c) > 126]
if bad:
found.append({'mode': 'cipher-font', 'page': s['page'],
'x': s['x'], 'y': s['y'], 'texts': [s['text']],
'detail': 'non-printable or non-ASCII characters in '
'font %s' % s['font']})
# Mode 3 -- a page whose text layer is far thinner than its siblings has
# probably been flattened to vectors, and its stamps and callouts will be
# invisible to any text-based diff. Judged per file against its own pages,
# since a two-page ticket's pages carry comparable amounts of text.
per_page = {}
for s in spans:
per_page[s['page']] = per_page.get(s['page'], 0) + 1
for p in range(npages):
n = per_page.get(p, 0)
if n < 20:
found.append({'mode': 'thin-text-layer', 'page': p,
'x': None, 'y': None, 'texts': [],
'detail': 'only %d text spans on this page; the page '
'may be flattened to vectors, so anything '
'not listed here is not "unchanged", it is '
'unread' % n})
# Mode 4 -- retired text underneath current text. Colocated spans with
# DIFFERENT content mean get_text() is returning at least one string that
# is not what the page shows.
cells = {}
for s in spans:
k = (s['page'], round(s['x'] / 2.0), round(s['y'] / 2.0))
cells.setdefault(k, []).append(s['text'])
for k, v in sorted(cells.items()):
if len(set(v)) > 1:
found.append({'mode': 'overlaid-text', 'page': k[0],
'x': k[1] * 2.0, 'y': k[2] * 2.0, 'texts': sorted(set(v)),
'detail': 'two different strings drawn at the same '
'point; one of them is not what the sheet '
'shows and the text layer does not say which'})
return found
# --------------------------------------------------------------------------
# region 1 -- title block
# --------------------------------------------------------------------------
# Anchored on the printed labels, not on coordinates. A label sits above-left
# of its value in a cell; the cell's right edge is the next label on the row.
TITLE_LABELS = [
'MARK #:', 'PROJECT NAME:', 'PROJECT #:', 'SEQUENCE #', 'LENGTH:',
'WIDTH:', 'QUANITY:', 'QUANTITY:', "CONCRETE (f'c):", "CONCRETE (f'ci):",
'THICKNESS:', 'WEIGHT (lbs.):', "ADD'L PRODUCTION:", "ADD'L FINISH:",
'PRODUCT CODE:', 'FACE MIX #', 'FACE MIX (C.Y.)', 'BASE MIX#',
'BASE MIX (C.Y.)', 'DRAWN BY:', 'ENG BY:',
]
# What a field's value is allowed to look like. A value that fails its own
# pattern is reported as unreadable rather than diffed -- a title block cell
# read from the wrong side of a boundary produces a plausible wrong string,
# and a diff of two plausible wrong strings is the worst output this tool
# could emit.
_FIELD_SHAPE = {
'LENGTH:': re.compile(r'^\d+\'[-\s]*\d+(\s\d+/\d+)?"$'),
'WIDTH:': re.compile(r'^\d+\'[-\s]*\d+(\s\d+/\d+)?"$'),
'THICKNESS:': re.compile(r'^\d+(\s\d+/\d+)?"$'),
'WEIGHT (lbs.):': re.compile(r'^[\d,]+$'),
'QUANITY:': re.compile(r'^\d+$'),
'QUANTITY:': re.compile(r'^\d+$'),
"CONCRETE (f'c):": re.compile(r'^\d+$'),
"CONCRETE (f'ci):": re.compile(r'^\d+$'),
'FACE MIX (C.Y.)': re.compile(r'^[\d.]+\s*CY$'),
'BASE MIX (C.Y.)': re.compile(r'^[\d.]+\s*CY$'),
}
_ROW_TOL = 1.5
_ROW_PITCH = 11.5
def title_block(spans, claim=None):
"""{label: {'value':..., 'ok':bool, 'x':, 'y':, 'page':}} keyed by label.
`claim`, if given, is a set that collects the id() of every span consumed
here, so the leftover-callout pass does not report title-block values a
second time under a different name.
"""
all_labels = [s for s in spans if s['text'] in TITLE_LABELS]
if not all_labels:
return {}
# Every page of a ticket carries the title block. Only the first page's
# values are reported as fields -- they are the same values -- but the
# LATER pages' cells must still be claimed, or the project name reappears
# on page 2 as a sheet callout that was "added to the drawing".
pages = sorted(set(s['page'] for s in all_labels))
out = {}
for page in pages:
_title_block_page(spans, all_labels, page, out if page == pages[0] else {},
claim)
_control_numbers(spans, out, claim)
return out
def _title_block_page(spans, all_labels, page, out, claim):
labels = [s for s in all_labels if s['page'] == page]
others = [s for s in spans
if s['page'] == page and s['text'] not in TITLE_LABELS]
rows = {}
for s in labels:
for ry in rows:
if abs(ry - s['y']) <= _ROW_TOL:
rows[ry].append(s)
break
else:
rows[s['y']] = [s]
row_ys = sorted(rows)
out = {}
for i, ry in enumerate(row_ys):
cells = sorted(rows[ry], key=lambda s: s['x'])
nxt_row = row_ys[i + 1] if i + 1 < len(row_ys) else None
y_hi = ry + _ROW_PITCH
if nxt_row is not None:
y_hi = min(y_hi, nxt_row - _ROW_TOL)
for j, lab in enumerate(cells):
x_lo = lab['x'] - 5.0
x_hi = cells[j + 1]['x'] - 1.0 if j + 1 < len(cells) else 1e9
vals = [s for s in others
if x_lo <= s['x'] < x_hi and ry - _ROW_TOL <= s['y'] <= y_hi]
vals.sort(key=lambda s: (s['y'], s['x']))
text = ' '.join(v['text'] for v in vals).strip()
shape = _FIELD_SHAPE.get(lab['text'])
ok = True
if text and shape and not shape.match(text):
ok = False
out[lab['text']] = {'value': text, 'ok': ok, 'page': page,
'x': lab['x'], 'y': lab['y']}
if claim is not None:
claim.add(id(lab))
for v in vals:
claim.add(id(v))
return out
def _control_numbers(spans, out, claim):
# Control numbers are the ticket's identity under this plant's rule (one
# ticket per control number, not per mark), and they are printed outside
# the title-block grid.
for s in spans:
if s['text'].upper().startswith('CONTROL NUMBER'):
near = [o for o in spans
if o['page'] == s['page'] and o['x'] > s['x']
and abs(o['y'] - s['y']) < 12 and re.match(r'^[\d,\s]+$', o['text'])]
near.sort(key=lambda o: o['x'])
out['CONTROL NUMBERS:'] = {
'value': ' '.join(o['text'] for o in near).strip(),
'ok': True, 'page': s['page'], 'x': s['x'], 'y': s['y']}
if claim is not None:
claim.add(id(s))
for o in near:
claim.add(id(o))
break
return out
# --------------------------------------------------------------------------
# region 2 -- bill of material
# --------------------------------------------------------------------------
def bom(spans, claim=None):
"""[{'qty','mark','desc','page','x','y'}] from the QTY/MARK/DESCRIPTION table.
Anchored on the header row. Description continuation lines carry no qty and
no mark, so rows are opened by a qty cell and closed by the next one.
"""
hdr_q = hdr_m = hdr_d = None
for s in spans:
if s['text'] == 'QTY':
cand_m = [o for o in spans if o['page'] == s['page']
and o['text'] == 'MARK' and abs(o['y'] - s['y']) < 2
and o['x'] > s['x']]
cand_d = [o for o in spans if o['page'] == s['page']
and o['text'] == 'DESCRIPTION' and abs(o['y'] - s['y']) < 2
and o['x'] > s['x']]
if cand_m:
hdr_q, hdr_m = s, cand_m[0]
hdr_d = cand_d[0] if cand_d else None
break
if hdr_q is None:
return []
page = hdr_q['page']
x_mark = hdr_m['x'] - 8.0
x_desc = (hdr_d['x'] - 4.0) if hdr_d else (x_mark + 40.0)
# The mark column is narrow. Widening it to "everything before the
# DESCRIPTION column" was measured to swallow a handling-diagram label
# sitting at x=1055 and report the strand row's mark as ERECTION -- a
# phantom "item added / item removed" pair on a ticket whose bill did not
# change at all.
x_mark_hi = min(x_desc, hdr_m['x'] + 35.0)
body = [s for s in spans if s['page'] == page and s['y'] > hdr_q['y'] + 2
and s['x'] >= hdr_q['x'] - 8.0
and s['text'] not in HANDLING_LABELS]
# A row is opened by a bare integer in the QTY column. Requiring the
# integer matters: a note block printed below the table sits in the same
# x-band, and without this it opens phantom rows and swallows the strand
# row's description.
qtys = sorted([s for s in body if s['x'] < x_mark and re.match(r'^\d+$', s['text'])],
key=lambda s: s['y'])
if not qtys:
return []
marks = sorted([s for s in body if x_mark <= s['x'] < x_mark_hi],
key=lambda s: (s['y'], s['x']))
descs = [s for s in body if s['x'] >= x_desc]
# Row pitch, measured on this ticket rather than assumed, bounds the last
# row so it cannot absorb whatever is printed under the table.
gaps = sorted(qtys[i + 1]['y'] - qtys[i]['y'] for i in range(len(qtys) - 1))
pitch = gaps[len(gaps) // 2] if gaps else 11.0
if claim is not None:
claim.add(id(hdr_q))
claim.add(id(hdr_m))
if hdr_d is not None:
claim.add(id(hdr_d))
rows = []
bbox = [hdr_q['x'] - 10.0, hdr_q['y'] - 4.0,
max(sp['x1'] for sp in body) if body else hdr_q['x'] + 250.0,
max(q['y'] for q in qtys) + pitch * 1.8]
for i, q in enumerate(qtys):
y_hi = q['y'] + pitch * 1.8
if i + 1 < len(qtys):
y_hi = min(y_hi, qtys[i + 1]['y'] - 2.0)
mk = [m for m in marks if q['y'] - 2.0 <= m['y'] < y_hi]
ds = sorted([d for d in descs if q['y'] - 2.0 <= d['y'] < y_hi],
key=lambda s: (s['y'], s['x']))
rows.append({
'qty': q['text'],
'mark': mk[0]['text'] if mk else '',
'desc': ' '.join(d['text'] for d in ds),
'page': page, 'x': q['x'], 'y': q['y'],
})
if claim is not None:
claim.add(id(q))
for s in mk + ds:
claim.add(id(s))
if claim is not None:
claim.add(('bom_bbox', page) + tuple(bbox))
return rows
# --------------------------------------------------------------------------
# region 3 -- handling diagrams
# --------------------------------------------------------------------------
HANDLING_LABELS = ('DUNNAGE', 'PICKING/STRIPPING', 'PICKING', 'STRIPPING',
'ERECTION', 'SHIPPING', 'TRANSPORTATION')
# A handling dimension is printed a fixed distance BELOW its diagram's label
# and starting at the label's own x. Both numbers are measurements, not
# guesses: across all 26 tickets in the two issues the offset is 43-46 points
# and never anything else, and the rule reproduces every diagram on every file.
#
# The rule this replaced -- "everything in the label's column, between this
# label and the next one down" -- was wrong because the diagrams are not always
# in one column. Half the set stacks all three at one x; the other half puts
# PICKING/STRIPPING at x=910 and ERECTION and DUNNAGE side by side at x=1055.
# WP194, WP195 and WP200 CHANGE LAYOUT between the two issues, so a
# column-based reader compared a four-dimension picking diagram against a
# two-dimension one and called it a material change on three tickets where
# nothing had moved.
_HANDLING_DY_MIN = 33.0
_HANDLING_DY_MAX = 62.0
_HANDLING_DX_MIN = -18.0
_HANDLING_DX_MAX = 210.0
def handling(spans):
"""{diagram label: [dim spans left-to-right]} for the little stage diagrams.
Order is PRINTED order, left to right. It is not mirrored and it is not
sorted by value. That matters more than it looks: reading a pair of dunnage
insets the other way round was measured, on this very ticket set, to turn
an engineer's CORRECTED sheet back into a failure. A diff that disagrees
with a fix the reader has already made is a diff he stops opening.
"""
labels = [s for s in spans if s['text'] in HANDLING_LABELS]
out = {}
for lab in sorted(labels, key=lambda s: (s['page'], s['y'], s['x'])):
dims = [s for s in spans
if s['page'] == lab['page']
and lab['y'] + _HANDLING_DY_MIN <= s['y'] <= lab['y'] + _HANDLING_DY_MAX
and lab['x'] + _HANDLING_DX_MIN <= s['x'] <= lab['x'] + _HANDLING_DX_MAX
and parse_dim(s['text']) is not None]
dims.sort(key=lambda s: s['x'])
key = lab['text']
n = 2
while key in out:
key = '%s (%d)' % (lab['text'], n)
n += 1
out[key] = dims
return out
# --------------------------------------------------------------------------
# region 4 -- note blocks
# --------------------------------------------------------------------------
_NOTE_HEAD_RE = re.compile(r'^(PRODUCTION\s+)?NOTES?\s*:?$', re.I)
def notes(spans, exclude=None):
"""[{'text','page','x','y'}] -- each note, from its heading down.
A note is its HEADING plus the lines under it, not "whatever pymupdf put
in the same block". Measured reason: on page 2 of one ticket the block
containing PRODUCTION NOTE also contains a 6" dimension and an SR567 bar
tag drawn nearby, and taking the block whole reported the note as removed
on one issue and added on the other because a bar tag next to it had
moved. Anchoring on the heading makes the note the paragraph an engineer
would read out, so an added or reworded note surfaces once, as a
paragraph, rather than as four orphan lines.
"""
by_block = {}
for s in spans:
by_block.setdefault(s['block'], []).append(s)
out = []
for _, ss in by_block.items():
keep = [x for x in sorted(ss, key=lambda x: (x['y'], x['x']))
if x['text'] not in TITLE_LABELS
and not SHEET_FURNITURE_RE.match(x['text'])
and not (exclude and id(x) in exclude)]
heads = [i for i, x in enumerate(keep) if _NOTE_HEAD_RE.match(x['text'])]
for n, i in enumerate(heads):
j = heads[n + 1] if n + 1 < len(heads) else len(keep)
body = keep[i:j]
out.append({'text': ' '.join(' '.join(x['text'] for x in body).split()),
'page': body[0]['page'], 'x': body[0]['x'],
'y': body[0]['y'],
'ids': [id(x) for x in body]})
out.sort(key=lambda n: (n['page'], n['y'], n['x']))
return out
# --------------------------------------------------------------------------
# region 5 -- dimension chains on the drawing
# --------------------------------------------------------------------------
_CHAIN_TOL = 2.5
def chains(spans, exclude):
"""Dimension strings grouped into the chains they are printed in.
A dimension chain is what a detailer actually edits: a run of segments
along one line, adding up to an overall. Grouping by the printed line and
comparing the run in order is what turns "two numbers differ somewhere on
a D-size sheet" into "the second and third segments of the top chain on the
bed plan swapped 3'-3"/1'-0" for 2'-9"/1'-6", total unchanged".
`exclude` is the set of span ids already claimed by another region, so a
handling-diagram dimension is not also reported as a loose chain.
"""
dims = [s for s in spans
if id(s) not in exclude and parse_dim(s['text']) is not None]
groups = []
for vert in (False, True):
pool = [s for s in dims if s['vertical'] == vert]
used = set()
for s in sorted(pool, key=lambda s: (s['page'], s['x'], s['y'])):
if id(s) in used:
continue
axis = 'x' if vert else 'y'
run = [o for o in pool if o['page'] == s['page']
and id(o) not in used and abs(o[axis] - s[axis]) <= _CHAIN_TOL]
for o in run:
used.add(id(o))
run.sort(key=lambda o: o['y'] if vert else o['x'])
other = 'y' if vert else 'x'
groups.append({
'page': s['page'],
'vertical': vert,
# `pos` is across the run (which line the chain sits on);
# `along` is where along that line it starts. Both are needed:
# two section details on the same page share an orientation and
# can share a `pos`, and matching on `pos` alone paired a
# dimension in SECTION A with one in SECTION B and called it a
# change on a ticket whose dimension text was byte-identical.
'pos': round(sum(o[axis] for o in run) / float(len(run)), 1),
'along': round(min(o[other] for o in run), 1),
'items': run,
})
groups.sort(key=lambda g: (g['page'], g['vertical'], g['pos']))
return groups
# --------------------------------------------------------------------------
# region 6 -- callouts and view titles
# --------------------------------------------------------------------------
_TITLE_WORDS = ('PLAN', 'SECTION', 'ELEVATION', 'DETAIL', 'VIEW', 'SCHEDULE')
def view_titles(spans):
"""Large-font strings that name a view, used as human-readable locators.
The size test alone is not enough: a section bubble's "A", the "MK" tag on
the bed plan and the scale string "1/4" = 1'-0"" are all set at the title
size, and naming a finding's location "MK, page 1" tells a reader nothing.
A view title on these sheets always contains one of the drawing words.
"""
out = []
for s in spans:
if s['size'] < 12.0:
continue
t = s['text'].upper()
if any(w in t for w in _TITLE_WORDS):
out.append(s)
return out
def locate(spans_titles, page, x, y, page_rect=(1224.0, 792.0)):
"""Name a point on the sheet the way somebody hunting for it would.
View title plus a coarse zone. "BED PLAN, page 1" on a D-size sheet is
still a lot of paper to search; "BED PLAN, page 1, lower left" is not.
"""
w, h = page_rect
col = 'left' if x < w / 3.0 else ('centre' if x < 2 * w / 3.0 else 'right')
row = 'upper' if y < h / 3.0 else ('middle' if y < 2 * h / 3.0 else 'lower')
zone = '%s %s' % (row, col)
cands = [t for t in spans_titles if t['page'] == page]
if not cands:
return 'page %d, %s' % (page + 1, zone)
best = min(cands, key=lambda t: (t['x'] - x) ** 2 + (t['y'] - y) ** 2)
return '%s, page %d, %s' % (best['text'], page + 1, zone)
def callouts(spans, claimed):
"""Everything left over that is a real annotation rather than a dimension.
Deliberately excludes anything already claimed by a region and anything
that parses as a dimension. What is left is part callouts (P76 W/ SR5602
(S.I.F.)), bar tags, spacing strings, section bubbles.
"""
out = []
for s in spans:
if id(s) in claimed:
continue
if parse_dim(s['text']) is not None:
continue
if len(s['text']) < 2:
continue
out.append(s)
return out
# --------------------------------------------------------------------------
# top level
# --------------------------------------------------------------------------
_TS_RE = re.compile(r'^\d{1,2}/\d{1,2}/\d{2,4}(\s+\d{1,2}:\d{2}(:\d{2})?\s*(AM|PM)?)?$',
re.I)
def extract(path):
"""One ticket PDF -> the structure ticketdelta.compare() consumes."""
spans, npages = read_spans(path)
haz = hazards(spans, npages)
claimed = set()
tb = title_block(spans, claimed)
bm = bom(spans, claimed)
bom_box = None
for c in claimed:
if isinstance(c, tuple) and c and c[0] == 'bom_bbox':
bom_box = {'page': c[1], 'x0': c[2], 'y0': c[3],
'x1': c[4], 'y1': c[5]}
claimed = set(c for c in claimed if not isinstance(c, tuple))
hl = handling(spans)
nt = notes(spans, claimed)
titles = view_titles(spans)
for _, dims in hl.items():
for d in dims:
claimed.add(id(d))
for s in spans:
if s['text'] in HANDLING_LABELS:
claimed.add(id(s))
# Note blocks are compared as paragraphs, so their lines must not also
# surface as loose callouts.
for n in nt:
for i in n['ids']:
claimed.add(i)
ch = chains(spans, claimed)
for g in ch:
for it in g['items']:
claimed.add(id(it))
stamps = [s['text'] for s in spans if _TS_RE.match(s['text'])]
return {
'path': path,
'file': os.path.basename(path),
'pages': npages,
'hazards': haz,
'title_block': tb,
'bom': bm,
'bom_bbox': bom_box,
'handling': hl,
'notes': nt,
'chains': ch,
'view_titles': titles,
'callouts': callouts(spans, claimed),
'timestamps': sorted(set(stamps)),
'span_count': len(spans),
}