import hashlib import re from collections import Counter from app.models.schematic import (Document, Sheet, Component, Symbol, Pin, SchematicObject, Diagnostic, Point) from .ole_reader import read_document from .images import read_images, asset_name from app.models.schematic import Primitive from .record_decoder import ParseError from .geometry import point, flag, integer, coordinate, number, bounds, union, transform_point, color from .primitives import decode, KINDS, SUPPORTED ELECTRICAL=['Input','Bidirectional','Output','Open collector','Passive','Tri-state','Open emitter','Power'] def parse_schdoc(data: bytes, filename: str, document_id: str) -> Document: fmt,streams,main,extra=read_document(data) if not main or 'HEADER' not in main[0].properties: raise ParseError('Missing schematic version header') header=main[0].properties version=header['HEADER'] if not re.fullmatch(r'Protel for Windows - Schematic Capture (?:Binary|Ascii|ASCII) File Version 5\.0',version): raise ParseError(f'Unsupported schematic version: {version[:150]}') records=[r for r in main+extra if r.index>=0] if len(records)>100000: raise ParseError('Combined record count limit exceeded') lookup={(r.stream,r.index):r for r in records} diagnostics=[] grouped={} def warn(code,message,r=None,severity='warning'): key=(code,r.properties.get('RECORD') if r else None,message if code not in ('unsupported_record','binary_record') else '') if key in grouped: grouped[key].count+=1 else: try: record_type=integer(r.properties,'RECORD') if r and 'RECORD' in r.properties else None except ValueError: record_type=None d=Diagnostic(severity=severity,code=code,message=message, record_id=r.id if r else None,record_type=record_type) grouped[key]=d;diagnostics.append(d) images, image_errors = read_images(data) for message in image_errors: warn('embedded_storage', message) sheet_records=[r for r in records if r.properties.get('RECORD')=='31'] if not sheet_records: raise ParseError('No schematic sheet record found') sheet_props=sheet_records[0].properties if len(sheet_records)>1: warn('multiple_sheets','Multiple sheet records found; only the first defines page properties') for s in streams: if s['name'] not in ('FileHeader','Additional','Storage'): warn('unhandled_stream',f"Uninterpreted OLE stream: {s['name']} ({s['size']} bytes)") if extra: warn('additional_indexing','Additional records use stream-local owner indexes; cross-stream ownership is not inferred',severity='info') components={} for r in records: if r.properties.get('RECORD')=='1': p=r.properties try: components[r.id]=Component(id=r.id,symbol_id=f'{r.id}:symbol',name=p.get('DESIGNITEMID',p.get('LIBREFERENCE','')), description=p.get('COMPONENTDESCRIPTION',''),library_reference=p.get('LIBREFERENCE',''),source_library=p.get('SOURCELIBRARYNAME',''), position=point(p),rotation=90*integer(p,'ORIENTATION'),mirrored=flag(p,'ISMIRRORED'),current_part=integer(p,'CURRENTPARTID',1),bounds=bounds([point(p)],5)) except ValueError as exc: raise ParseError(f'Invalid component {r.id}: {exc}') from exc def owner_chain(r): chain=[];seen={r.id};current=r while 'OWNERINDEX' in current.properties: index=integer(current.properties,'OWNERINDEX',-1) parent=lookup.get((current.stream,index)) if not parent: break if parent.id in seen: raise ValueError('Cyclic ownership') seen.add(parent.id);chain.append(parent);current=parent if len(chain)>64: raise ValueError('Excessive ownership depth') return chain # Collect component/sheet parameter values before resolving text expressions. parameter_maps={} for r in records: if r.properties.get('RECORD') in ('34','41'): p=r.properties try: chain=owner_chain(r) except ValueError as exc: warn('ownership',str(exc),r);continue c=next((components[q.id] for q in chain if q.id in components),None) key=p.get('NAME','Designator' if p.get('RECORD')=='34' else '') value=p.get('TEXT','') owner=c.id if c else 'sheet-0' parameter_maps.setdefault(owner,{})[key.upper()]=value if c: c.parameters[key]=value if p.get('RECORD')=='34': c.designator=value if key.upper()=='COMMENT': c.comment=value if key.upper()=='VALUE': c.value=value for c in components.values(): c.value=c.value or c.comment objects=[];primitives=[] counts=Counter() for r in records: p=r.properties try: t=integer(p,'RECORD',-1);counts[str(t)]+=1 chain=owner_chain(r) c=next((components[q.id] for q in [r]+chain if q.id in components),None) owner=lookup.get((r.stream,integer(p,'OWNERINDEX',-1))) active=True if c and r.id!=c.id: cp=lookup[(r.stream,int(c.id.split(':')[-1]))].properties for q in [r]+chain: if q.id==c.id: break part=integer(q.properties,'OWNERPARTID',-1) mode=integer(q.properties,'OWNERPARTDISPLAYMODE',0) if part not in (-1,0,c.current_part) or mode!=integer(cp,'DISPLAYMODE',0): active=False visible=active and not flag(p,'ISHIDDEN') if t==41 and any(q.properties.get('RECORD')=='48' for q in chain): visible=False if t==2 and integer(p,'PINCONGLOMERATE')&4: visible=False if p.get('TEXT','').startswith('='): key=p['TEXT'][1:].upper() value=parameter_maps.get(c.id if c else 'sheet-0',{}).get(key,parameter_maps.get('sheet-0',{}).get(key)) if value is not None: p['_RESOLVED_TEXT']=value obj=SchematicObject(id=r.id,kind=KINDS.get(t,'unsupported'),record_type=t,source_stream=r.stream,source_index=r.index, owner_id=owner.id if owner else None,component_id=c.id if c else None,visible=visible,position=point(p),bounds=bounds([point(p)]),properties=p) if r.binary: warn('binary_record','Binary schematic record retained by index but payload is unsupported',r) elif t == 30 and visible: image = images.get(asset_name(p.get('FILENAME', ''))) if image: box = bounds([point(p), point(p, 'CORNER')]) shape = Primitive(id=r.id+':image', kind='image', bounds=box, object_id=r.id, component_id=c.id if c else None, image_data=image, keep_aspect=flag(p, 'KEEPASPECT')) primitives.append(shape); obj.primitive_ids=[shape.id]; obj.bounds=box else: warn('missing_image', 'Embedded raster unavailable; external files are never fetched', r) elif t not in SUPPORTED: warn('unsupported_record',f'Unsupported schematic record type {t}; properties preserved',r) elif visible: shapes=decode(r,c.id if c else None,sheet_props,warn,lookup) primitives.extend(shapes);obj.primitive_ids=[s.id for s in shapes] if shapes: obj.bounds=union([s.bounds for s in shapes]) if t==2: bits=integer(p,'PINCONGLOMERATE');length=coordinate(p,'PINLENGTH') end=transform_point(Point(x=length,y=0),obj.position,(bits&3)*90) obj.connection_points=[end] pin=Pin(id=r.id,name=p.get('NAME',''),number=p.get('DESIGNATOR',''),electrical_type=ELECTRICAL[max(0,min(7,integer(p,'ELECTRICAL')))], position=obj.position,connection=end,length=length,rotation=(bits&3)*90,visible=visible,active=active) if c: c.pins.append(pin) else: warn('orphan_pin','Pin has no component owner',r) elif t in (26,27): n=integer(p,'LOCATIONCOUNT') if not 0<=n<=20000: raise ValueError('Invalid electrical point count') obj.connection_points=[Point(x=coordinate(p,f'X{i}'),y=-coordinate(p,f'Y{i}')) for i in range(1,n+1)] elif t in (17,18,25,29,37): obj.connection_points=[obj.position] if c: c.object_ids.append(obj.id);c.primitive_ids.extend(obj.primitive_ids) objects.append(obj) except (ValueError,OverflowError,KeyError) as exc: warn('malformed_record',f'Record not rendered: {str(exc)[:180]}',r,severity='error') objects.append(SchematicObject(id=r.id,kind='malformed',record_type=-1,source_stream=r.stream,source_index=r.index, visible=False,position=Point(x=0,y=0),bounds=bounds([]),properties=p)) if len(primitives)>250000: raise ParseError('Drawable count limit exceeded') if not primitives: raise ParseError('No supported visible geometry found in this schematic') by_component={} for shape in primitives: if shape.component_id: by_component.setdefault(shape.component_id,[]).append(shape.bounds) for c in components.values(): if c.id in by_component: c.bounds=union(by_component[c.id]) else: warn('empty_symbol',f'Component {c.designator or c.id} has no visible supported symbol geometry') # Standard sheet presets in 10-mil units. Custom page dimensions win only when enabled. sizes={0:(1150,760),1:(1550,1110),2:(2230,1570),3:(3150,2230),4:(4460,3150),5:(950,750),6:(1500,950),7:(2000,1500),8:(3200,2000),9:(4200,3200),10:(1100,850),11:(1400,850),12:(1700,1100),13:(990,790),14:(1540,990),15:(2060,1560),16:(3260,2060),17:(4280,3280)} style=integer(sheet_props,'SHEETSTYLE') if style not in sizes and not flag(sheet_props,'USECUSTOMSHEET'): raise ParseError('Unsupported sheet dimensions') w,h=sizes.get(style,(1150,760)) if flag(sheet_props,'USECUSTOMSHEET'): w,h=coordinate(sheet_props,'CUSTOMX'),coordinate(sheet_props,'CUSTOMY') if integer(sheet_props,'WORKSPACEORIENTATION') and w>h: w,h=h,w if w <= 0 or h <= 0: raise ParseError('Sheet dimensions must be positive') page=bounds([Point(x=0,y=-h),Point(x=w,y=0)]) content=union([s.bounds for s in primitives]) if flag(sheet_props,'BORDERON') or flag(sheet_props,'TITLEBLOCKON'): warn('page_decoration','Automatic Altium page border, zones and title block are not reconstructed. Explicit template geometry is rendered.') try: if 'WEIGHT' in header and int(header['WEIGHT'])!=len(main)-1: warn('record_count_mismatch',f"Header declares {header['WEIGHT']} records; found {len(main)-1} in FileHeader") except ValueError: warn('record_count_mismatch','Header record count is not numeric') warn('connectivity_scope','Connection anchors and wire vertices are extracted. Net names are not a compiled netlist; bus expansion, harnesses and cross-sheet connectivity are not resolved.',severity='info') warn('font_metrics','Exact original fonts are required on the client. Font size conversion and glyph metrics have not been validated against Altium.',severity='warning') warn('pin_text_metrics','Default pin text offsets and port text anchors require validation against a trusted Altium export.',severity='warning') return Document(id=document_id,filename=filename, metadata={'format':fmt,'header':version,'minor_version':header.get('MINORVERSION'), 'sha256':hashlib.sha256(data).hexdigest(),'input_bytes':len(data),'streams':streams, 'record_counts':dict(counts),'record_count':len(records),'component_count':len(components), 'primitive_count':len(primitives),'units':'10mil','coordinates':'X right, Y down; Y inverted once from Altium', 'compatibility':'partial' if any(d.severity in ('warning','error') for d in diagnostics) else 'supported_subset', 'header_properties':header}, sheet=Sheet(name=filename,background=color(sheet_props.get('AREACOLOR','16317695')),properties=sheet_props,bounds=page),bounds=content, components=list(components.values()),symbols=[Symbol(id=c.symbol_id,component_id=c.id,primitive_ids=c.primitive_ids) for c in components.values()], objects=objects,primitives=primitives,diagnostics=diagnostics)