# -*- coding: utf-8 -*- """Pull the result tables out of an Eriksson Beam v5.9 PDF, deterministically. python beamcalc/tools/eriksson_extract.py Uses pymupdf word coordinates: every printed table row is one text line, so words are grouped by their y position and split on the known column count. Nothing is inferred -- a row that does not have the expected number of numeric cells is skipped and counted in `skipped`. Only numbers are kept; the title block (client / job / engineer) is not copied out. Tables recognised (first run in the document only -- the "END CHECK" strut and tie re-run that some packages append is ignored on purpose): summary -- the "Summary" page numbers (reactions, bearing, strength, service) flexure -- In-Place Strength / Flexure Summary Table (14 columns) shear -- In-Place Strength / Shear Summary Table (15 columns) tension -- In-Place Service / Tension: station, psi, cracking psi, Mz comp_total -- Total Compression: station, psi, Mz comp_sust -- Sustained Compression: station, psi, Mz deflection -- Camber and Deflections: rows {case, stage, mult, values[11]} """ from __future__ import annotations import json import re import sys import pymupdf NUM = re.compile(r'^-?\d+(\.\d+)?$') def _num(t): if t == 'NC': return None return float(t) def lines_of(page): rows = {} for x0, y0, x1, y1, word, b, l, n in page.get_text('words'): rows.setdefault(round(y0), []).append((x0, word)) out = [] for y in sorted(rows): out.append([w for x, w in sorted(rows[y])]) return out FLEX_COLS = ['x_ft', 'util', 'mu', 'phi', 'tc', 'phi_mn', 'mcr12', 'c', 'theta', 'as', 'as_c', 'aps', 'loss', 'd'] SHEAR_COLS = ['x_ft', 'util', 'vu', 'phi_vn', 'vc', 'vs_prov', 'bwd', 'legs', 'av_min', 'av_prov', 'av_req', 's', 's_max', 'f_act', 'f_alw'] def parse_row(words, cols): if len(words) != len(cols): return None row = {} for c, w in zip(cols, words): if c == 'tc': row[c] = w continue if not (NUM.match(w) or w == 'NC'): return None row[c] = _num(w) return row def extract(pdf_path): doc = pymupdf.open(pdf_path) out = {'flexure': [], 'shear': [], 'tension': [], 'comp_total': [], 'comp_sust': [], 'deflection': [], 'summary': {}, 'skipped': 0, 'pages': len(doc)} mode = None # which table the following numeric rows belong to stage = None # 'strength' / 'service' / 'stripping' ... from page headers defl_stage = None defl_case = None seen_summary = False seen_run_end = False for pno, page in enumerate(doc, 1): text = page.get_text() if 'Project Information' in text and seen_summary: # a second Eriksson run begins (END CHECK etc.) -- stop seen_run_end = True if seen_run_end: break for words in lines_of(page): line = ' '.join(words) # --- section headers drive the mode if line.startswith('In-Place Strength Summary'): stage = 'strength'; mode = None elif line.startswith('In-Place Service Summary'): stage = 'service'; mode = None elif line.startswith('Camber and Deflections') and 'Note' not in line: stage = 'deflection'; mode = 'defl' elif line.split(' ')[0] in ('Stripping', 'Handling', 'Erection', 'Transfer') and 'Summary' in line: stage = 'handling'; mode = None elif line == 'Flexure' and stage == 'strength': mode = 'flexure' elif line.startswith('Pure Shear') and stage == 'strength': mode = 'shear' elif line.startswith('Loc. Util Vu') and stage == 'strength': mode = 'shear' elif line.startswith('Loc. Util.') and stage == 'strength': mode = 'flexure' if mode != 'shear' else mode elif line.startswith('Precast') and stage == 'service': pass elif line.startswith('Location Tension Cracking Mz'): mode = 'tension' elif line.startswith('Total Compression'): mode = 'comp_total' elif line.startswith('Sustained Compression'): mode = 'comp_sust' elif line.startswith('Servicability Class') or line.startswith('Location Tension Class'): mode = 'svc_class' elif line.startswith('Torsion') and stage == 'strength': mode = None if stage == 'deflection': if line in ('Start of Construction', 'End of Construction'): defl_stage = line elif line.startswith('Final') and len(words) == 1: defl_stage = 'Final' elif line.startswith('Deflections Occuring Prior'): defl_stage = 'Final prior' elif line.startswith('Deflections Occuring After'): defl_stage = 'Final after' elif line == 'Total Deflections': defl_stage = 'Total' elif len(words) == 1 and not NUM.match(words[0]): if words[0] in ('SW', 'Dnc', 'Dt', 'Dc', 'Live', 'Roof', 'Wind', 'Seismic', 'PS', 'Total'): defl_case = words[0] elif len(words) == 2 and words[0] == 'Sust.' and words[1] == 'Live': defl_case = 'Sust. Live' elif len(words) == 12 and all(NUM.match(w) for w in words) and defl_case: out['deflection'].append({'stage': defl_stage, 'case': defl_case, 'mult': float(words[0]), 'values': [float(w) for w in words[1:]], 'page': pno}) defl_case = None elif len(words) == 11 and all(NUM.match(w) for w in words) and defl_case is None and not out.get('defl_x'): out['defl_x'] = [float(w) for w in words] continue if mode == 'flexure': r = parse_row(words, FLEX_COLS) if r: r['page'] = pno; out['flexure'].append(r) elif words and NUM.match(words[0]) and len(words) > 8: out['skipped'] += 1 elif mode == 'shear': r = parse_row(words, SHEAR_COLS) if r: r['page'] = pno; out['shear'].append(r) elif words and NUM.match(words[0]) and len(words) > 8: out['skipped'] += 1 elif mode == 'tension' and len(words) == 4 and all(NUM.match(w) for w in words): out['tension'].append({'x_ft': float(words[0]), 'f': float(words[1]), 'f_cr': float(words[2]), 'mz': float(words[3]), 'page': pno}) elif mode in ('comp_total', 'comp_sust') and len(words) == 3 and all(NUM.match(w) for w in words): out[mode].append({'x_ft': float(words[0]), 'f': float(words[1]), 'mz': float(words[2]), 'page': pno}) # --- the one-page Summary block (regex over the page text) if 'Stage: In-Place Strength' in text and 'Stage: In-Place Service' in text and not seen_summary: seen_summary = True s = out['summary'] flat = text.replace('\n', ' ') def grab(pattern, cast=float): m = re.search(pattern, flat) return cast(m.group(1)) if m else None s['flex_util'] = grab(r'Stage: In-Place Strength.*?Utilization\s*:\s*([\d.]+)') s['flex_phi_mn'] = grab(r'Stage: In-Place Strength.*?ϕMn \(k-ft\):\s*([\d.-]+)') s['flex_mu'] = grab(r'Stage: In-Place Strength.*?Mu \(k-ft\):\s*([\d.-]+)') s['av_s'] = grab(r'Av/s \(in²/ft\):\s*([\d.]+)') s['vc'] = grab(r'Vc \(k\)\s*:\s*([\d.]+)') s['vu'] = grab(r'Vu \(k\)\s*:\s*([\d.]+)') s['defl_total_up'] = grab(r'Defl\. Total\s*↑ \(in\):\s*([\d.-]+)') s['defl_total_down'] = grab(r'Defl\. Total.*?↓ \(in\):\s*([\d.-]+)') s['defl_total_limit'] = grab(r'Defl\. Total.*?Limit \(in\):\s*([\d.]+)') m = re.search(r'LL\s*↑ \(in\):\s*([\d.-]+)\s*↓ \(in\):\s*([\d.-]+)\s*Limit \(in\):\s*([\d.]+)', flat) if m: s['defl_live_up'], s['defl_live_down'], s['defl_live_limit'] = (float(v) for v in m.groups()) m = re.search(r'Defl\. Total\s*↑ \(in\):\s*([\d.-]+)\s*↓ \(in\):\s*([\d.-]+)\s*Limit \(in\):\s*([\d.]+)', flat) if m: s['defl_total_up'], s['defl_total_down'], s['defl_total_limit'] = (float(v) for v in m.groups()) s['comp_tot_max'] = grab(r'Comp\. Tot\. Max \(psi\):\s*([\d.]+)') s['comp_sust_max'] = grab(r'Comp\. Sust\.Max \(psi\):\s*([\d.]+)') s['tension_max'] = grab(r'Tension\s*Max \(psi\):\s*([\d.]+)') s['class_u_limit'] = grab(r'Class U Limit \(psi\):\s*([\d.]+)') s['class_t_limit'] = grab(r'Class T Limit \(psi\):\s*([\d.]+)') m = re.search(r'Fy\s+([\d.]+)/([\d.]+)\s+([\d.]+)/([\d.]+)\s+([\d.]+)/([\d.]+)\s+([\d.]+)/([\d.]+)', flat) if m: g = [float(v) for v in m.groups()] s['reactions'] = {'deadnc': g[0:2], 'deadc': g[2:4], 'live': g[4:6], 'roof': g[6:8]} m = re.search(r'Left\s+([\d.]+)\s+([\d.]+)\s+.*?Right\s+([\d.]+)\s+([\d.]+)', flat) if m: s['bearing'] = {'left': {'vu': float(m.group(1)), 'phi_vn_plain': float(m.group(2))}, 'right': {'vu': float(m.group(3)), 'phi_vn_plain': float(m.group(4))}} m = re.search(r'Stage: Stripping.*?Max Shear \(k\)\s*:\s*([\d.]+)\s*Max Tension \(k\):\s*([\d.]+).*?Compression \(psi\):\s*(\d+)/(\d+)\s*Tension \(psi\)\s*:\s*(\d+) / (\d+).*?Utilization:\s*([\d.]+)\s*ϕMn \(k-ft\):\s*([\d.]+)\s*Mu \(k-ft\):\s*([\d.]+)', flat) if m: g = m.groups() s['stripping'] = {'max_shear': float(g[0]), 'max_tension': float(g[1]), 'comp': [float(g[2]), float(g[3])], 'tens': [float(g[4]), float(g[5])], 'util': float(g[6]), 'phi_mn': float(g[7]), 'mu': float(g[8])} s['page'] = pno return out def main(): src, dst = sys.argv[1], sys.argv[2] data = extract(src) with open(dst, 'w', encoding='utf-8') as fh: json.dump(data, fh, indent=1) print('%s: flexure %d rows, shear %d, tension %d, comp %d/%d, defl rows %d, skipped %d, summary keys %d' % (dst, len(data['flexure']), len(data['shear']), len(data['tension']), len(data['comp_total']), len(data['comp_sust']), len(data['deflection']), data['skipped'], len(data['summary']))) if __name__ == '__main__': main()