Files
altium-render-engine/backend/app/parser/schdoc_parser.py
T
nearology 56c5ee23d3
Verify / test (push) Failing after 2m54s
first commit
2026-11-05 17:31:17 +03:30

184 خطوط
12 KiB
Python

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)