first commit
Verify / test (push) Failing after 2m54s

This commit is contained in:
2026-11-05 17:31:17 +03:30
کامیت 56c5ee23d3
63فایلهای تغییر یافته به همراه2749 افزوده شده و 0 حذف شده
@@ -0,0 +1,48 @@
import math
from app.models.schematic import Point, Bounds
MAX_COORDINATE = 10_000_000
def number(p, key, default=0):
v = float(p.get(key, default))
if not math.isfinite(v) or abs(v) > MAX_COORDINATE:
raise ValueError(f'Invalid or excessive numeric property {key}')
return v
def integer(p, key, default=0):
return int(number(p, key, default))
def flag(p, key):
return p.get(key, '').upper() in ('T', 'TRUE', '1')
def coordinate(p, key):
# Stored base is 10 mil; fractional field is 1/10000 mil.
return number(p, key) + number(p, key + '_FRAC') / 100000
def point(p, prefix='LOCATION'):
return Point(x=coordinate(p, prefix+'.X'), y=-coordinate(p, prefix+'.Y'))
def transform_point(p: Point, origin: Point, rotation: float, mirrored=False):
"""For local generated markers only. SchDoc child geometry is already placed."""
x = -p.x if mirrored else p.x
a = math.radians(-rotation)
return Point(x=origin.x+x*math.cos(a)-p.y*math.sin(a),
y=origin.y+x*math.sin(a)+p.y*math.cos(a))
def bounds(points, padding=0):
if not points:
return Bounds(x=0,y=0,width=0,height=0)
xs,ys = [p.x for p in points],[p.y for p in points]
return Bounds(x=min(xs)-padding,y=min(ys)-padding,
width=max(xs)-min(xs)+2*padding,height=max(ys)-min(ys)+2*padding)
def union(boxes):
return bounds([p for b in boxes for p in
(Point(x=b.x,y=b.y),Point(x=b.x+b.width,y=b.y+b.height))])
def color(value):
n = int(value) & 0xffffff
return f'#{n&255:02x}{n>>8&255:02x}{n>>16&255:02x}'
def poly_path(points, closed=False):
return ' '.join(('M' if i==0 else 'L')+f'{p.x:.15g},{p.y:.15g}' for i,p in enumerate(points)) + (' Z' if closed else '')
@@ -0,0 +1,58 @@
"""Decode only bounded, embedded raster assets. Never follow document filenames."""
import base64
import io
import struct
import warnings
import zlib
import olefile
from PIL import Image
from .record_decoder import binary_records, ParseError
MAX_ASSET = 16 * 1024 * 1024
MAX_PIXELS = 16_000_000
Image.MAX_IMAGE_PIXELS = MAX_PIXELS
def asset_name(name): return name.replace('\\', '/').rsplit('/', 1)[-1].casefold()
def read_images(data):
if not data.startswith(olefile.MAGIC): return {}, []
assets, errors = {}, []
try:
with olefile.OleFileIO(io.BytesIO(data)) as ole:
if not ole.exists('Storage'): return assets, errors
raw = ole.openstream('Storage').read()
total = 0
for record in binary_records(raw, 'Storage'):
if not record.binary: continue
payload = base64.b64decode(record.properties['BINARY_BASE64'])
if len(payload) < 6 or payload[0] != 0xd0:
errors.append('Unsupported embedded storage framing'); continue
n = payload[1]
if len(payload) < n+6: raise ParseError('Truncated embedded storage asset')
name = asset_name(payload[2:2+n].decode('cp1252', errors='replace'))
count = struct.unpack_from('<I', payload, 2+n)[0]
if count != len(payload)-6-n: raise ParseError('Invalid embedded compressed size')
decoder = zlib.decompressobj()
raster = decoder.decompress(payload[6+n:], MAX_ASSET+1)
if len(raster) > MAX_ASSET or not decoder.eof or decoder.unused_data:
raise ParseError('Embedded raster exceeds decoding limit or has invalid compression')
total += len(raster)
if total > 32*1024*1024: raise ParseError('Total embedded raster size limit exceeded')
with warnings.catch_warnings():
warnings.simplefilter('error', Image.DecompressionBombWarning)
with Image.open(io.BytesIO(raster)) as source:
if source.width*source.height > MAX_PIXELS: raise ParseError('Embedded raster pixel limit exceeded')
if source.format not in {'PNG','BMP','JPEG','TIFF','GIF'}:
errors.append('Unsupported embedded raster format'); continue
if getattr(source, 'n_frames', 1) != 1:
errors.append('Multi-frame raster skipped'); continue
image = source.convert('RGBA')
output = io.BytesIO(); image.save(output, format='PNG')
png = output.getvalue()
if len(png) > MAX_ASSET: raise ParseError('Converted raster exceeds asset limit')
if name in assets:
errors.append('Ambiguous embedded filename skipped'); assets[name] = None
else: assets[name] = 'data:image/png;base64,' + base64.b64encode(png).decode('ascii')
except Exception as exc:
errors.append('Embedded storage not completely decoded: ' + str(exc)[:160])
return assets, errors
@@ -0,0 +1,28 @@
import io
import struct
import olefile
from .record_decoder import ParseError, binary_records, ascii_records, detect_format
MAX_STREAM_BYTES = 32 * 1024 * 1024
MAX_TOTAL_BYTES = 64 * 1024 * 1024
def read_document(data):
fmt=detect_format(data)
if fmt=='ascii':
return fmt,[],ascii_records(data),[]
try:
with olefile.OleFileIO(io.BytesIO(data),raise_defects=olefile.DEFECT_INCORRECT) as ole:
entries=ole.listdir(streams=True,storages=False)
if len(entries)>256: raise ParseError('Too many OLE streams')
streams=[{'name':'/'.join(p),'size':ole.get_size(p)} for p in entries]
if any(s['size']>MAX_STREAM_BYTES for s in streams) or sum(s['size'] for s in streams)>MAX_TOTAL_BYTES:
raise ParseError('OLE stream size limit exceeded')
if not ole.exists('FileHeader') or ole.get_type('FileHeader')!=2:
raise ParseError('OLE container has no FileHeader stream')
main=binary_records(ole.openstream('FileHeader').read(),'FileHeader')
extra=[]
if ole.exists('Additional') and ole.get_type('Additional')==2:
extra=binary_records(ole.openstream('Additional').read(),'Additional')
return fmt,streams,main,extra
except (OSError,IOError,ValueError,IndexError,struct.error) as exc:
raise ParseError(f'Invalid OLE schematic: {exc}') from exc
@@ -0,0 +1,200 @@
"""Decode actual symbol geometry and document-defined electrical marker styles."""
import math
import textwrap
from app.models.schematic import Primitive, Point
from .geometry import number, integer, coordinate, point, flag, bounds, color, poly_path, transform_point
KINDS={1:'component',2:'pin',3:'ieee_symbol',4:'label',5:'bezier',6:'polyline',7:'polygon',8:'ellipse',9:'pie',10:'round_rectangle',11:'elliptical_arc',12:'arc',13:'line',14:'rectangle',15:'sheet_symbol',16:'sheet_entry',17:'power_port',18:'port',22:'no_erc',25:'net_label',26:'bus',27:'wire',28:'text_frame',29:'junction',30:'image',31:'sheet',32:'sheet_name',33:'sheet_filename',34:'designator',37:'bus_entry',39:'template',41:'parameter',43:'parameter_set',44:'implementation_list',45:'implementation',46:'pin_map',47:'pin_map',48:'implementation_parameter',209:'text_frame',225:'dashed_rectangle'}
SUPPORTED=set(KINDS)-{3,30,43}
def decode(record, component_id, sheet, warn, records):
p=record.properties
t=integer(p,'RECORD')
pos=point(p)
output=[]
stroke=color(p.get('COLOR','8388608' if t in (25,26,27) else '128'))
fill=color(p.get('AREACOLOR','16777215')) if flag(p,'ISSOLID') and not flag(p,'TRANSPARENT') else 'none'
width=(0.4,1,2,4)[min(3,max(0,integer(p,'LINEWIDTH')))]
dash=[4,2] if integer(p,'LINESTYLE') or t==225 else []
if integer(p,'LINESTYLE'): warn('dash_metrics','Non-solid line dash spacing requires Altium reference validation',record)
def path(points=None,d=None,closed=False,fill_=None,stroke_=None,width_=None,box=None):
output.append(Primitive(id=f'{record.id}:p{len(output)}',kind='path',
object_id=record.id,component_id=component_id,path=d if d is not None else poly_path(points or [],closed),
bounds=box or bounds(points or [],width+1),stroke=stroke_ or stroke,fill=fill if fill_ is None else fill_,
width=width_ or width,dash=dash))
def text(value,at=pos,angle=None,font=None,anchor=None,baseline=None,frame=None,wrap_width=None):
if not value: return
fid=font or integer(p,'FONTID',integer(sheet,'SYSTEMFONT',1))
# Browser em size approximation: Altium font size is not a CSS point size.
# This yields glyph cap heights close to the KiCad importer's size/2 stroke text.
size=max(1,min(300,number(sheet,f'SIZE{fid}',10)))*0.9
just=integer(p,'JUSTIFICATION')
a=anchor or ('start','middle','end')[just%3]
base=baseline or ('alphabetic','middle','hanging')[min(2,max(0,just//3))]
rotation=-90*integer(p,'ORIENTATION') if angle is None else angle
raw=value.replace('~1','\n')
# Altium backslash suffix marks an overbar on the preceding character.
clean=raw.replace('\\','')
if raw.startswith('='):
key=raw[1:].upper()
resolved=p.get('_RESOLVED_TEXT')
if resolved is not None: clean=resolved
else: warn('unresolved_text',f'Unresolved parameter reference {raw}',record)
lines=clean.splitlines() or ['']
if wrap_width is not None:
columns=max(1,int(wrap_width/(size*0.55)))
lines=[line for paragraph in lines for line in (textwrap.wrap(paragraph,width=columns,break_long_words=True,break_on_hyphens=False,replace_whitespace=False) or [''])]
for line_i,line in enumerate(lines):
loc=Point(x=at.x,y=at.y+line_i*size*1.2)
w=max(size*.6,len(line)*size*.8)
x={'start':0,'middle':-w/2,'end':-w}[a]
y={'alphabetic':-size,'middle':-size/2,'hanging':0}[base]
corners=[transform_point(Point(x=cx,y=cy),loc,-rotation,False) for cx,cy in [(x,y),(x+w,y),(x+w,y+size*1.3),(x,y+size*1.3)]]
box=bounds(corners,2)
if frame:
x0,y0=max(box.x,frame.x),max(box.y,frame.y)
x1,y1=min(box.x+box.width,frame.x+frame.width),min(box.y+box.height,frame.y+frame.height)
if x1<=x0 or y1<=y0: continue
from app.models.schematic import Bounds
box=Bounds(x=x0,y=y0,width=x1-x0,height=y1-y0)
output.append(Primitive(id=f'{record.id}:p{len(output)}',kind='text',object_id=record.id,
component_id=component_id,text=line,position=loc,rotation=rotation,mirrored=False,
bounds=box,clip=frame,stroke=color(p.get('TEXTCOLOR',p.get('COLOR','128'))),fill='none',
font_size=size,font_family=sheet.get(f'FONTNAME{fid}','Arial'),bold=flag(sheet,f'BOLD{fid}'),
italic=flag(sheet,f'ITALIC{fid}'),underline=flag(sheet,f'UNDERLINE{fid}'),anchor=a,baseline=base))
if '\\' in raw:
warn('text_overbar','Overbar text is displayed without its per-character overline',record)
def local(points,origin=pos,rotation=None,closed=False,fill_=None):
pts=[transform_point(Point(x=x,y=y),origin,90*integer(p,'ORIENTATION') if rotation is None else rotation) for x,y in points]
path(pts,closed=closed,fill_=fill_)
def ellipse(cx,cy,rx,ry,solid='none'):
if rx<=0 or ry<=0: return
d=f'M {cx-rx:.15g},{cy:.15g} A {rx:.15g},{ry:.15g} 0 1 0 {cx+rx:.15g},{cy:.15g} A {rx:.15g},{ry:.15g} 0 1 0 {cx-rx:.15g},{cy:.15g} Z'
path(d=d,fill_=solid,box=bounds([Point(x=cx-rx,y=cy-ry),Point(x=cx+rx,y=cy+ry)],width))
if t in (5,6,7,26,27):
n=integer(p,'LOCATIONCOUNT')
extra=integer(p,'EXTRALOCATIONCOUNT')
if n<0 or extra<0 or n+extra>20000: raise ValueError('Invalid point count')
pts=[Point(x=coordinate(p,f'X{i}'),y=-coordinate(p,f'Y{i}')) for i in range(1,n+1)]
pts += [Point(x=coordinate(p,f'EX{i}'),y=-coordinate(p,f'EY{i}')) for i in range(n+1,n+extra+1)]
if t==5:
if len(pts)<4 or (len(pts)-1)%3: warn('bezier_points','Non-cubic Bezier point sequence is unsupported',record)
else:
d=f'M{pts[0].x:.15g},{pts[0].y:.15g} '
for i in range(1,len(pts),3): d+='C'+' '.join(f'{q.x:.15g},{q.y:.15g}' for q in pts[i:i+3])+' '
path(pts,d=d,fill_='none')
elif len(pts)>1: path(pts,closed=t==7,fill_=fill if t==7 else 'none')
if integer(p,'STARTLINESHAPE') or integer(p,'ENDLINESHAPE'):
warn('line_decoration','Line end decorations are not rendered',record)
elif t in (13,37): path([pos,point(p,'CORNER')],fill_='none')
elif t in (10,14,15,28,209,225):
corner=point(p,'CORNER')
if t==15: corner=Point(x=pos.x+coordinate(p,'XSIZE'),y=pos.y+coordinate(p,'YSIZE'))
x,y=min(pos.x,corner.x),min(pos.y,corner.y); w,h=abs(pos.x-corner.x),abs(pos.y-corner.y)
pts=[Point(x=x,y=y),Point(x=x+w,y=y),Point(x=x+w,y=y+h),Point(x=x,y=y+h)]
if t==10:
rx=min(w/2,abs(coordinate(p,'CORNERXRADIUS'))); ry=min(h/2,abs(coordinate(p,'CORNERYRADIUS')))
if rx and ry:
d=f'M{x+rx},{y} H{x+w-rx} A{rx},{ry} 0 0 1 {x+w},{y+ry} V{y+h-ry} A{rx},{ry} 0 0 1 {x+w-rx},{y+h} H{x+rx} A{rx},{ry} 0 0 1 {x},{y+h-ry} V{y+ry} A{rx},{ry} 0 0 1 {x+rx},{y} Z'
path(pts,d=d)
else: path(pts,closed=True)
elif t not in (28,209) or flag(p,'SHOWBORDER') or fill!='none': path(pts,closed=True,fill_=fill,stroke_=stroke if t not in (28,209) or flag(p,'SHOWBORDER') else fill)
if t in (28,209):
frame=bounds(pts) if flag(p,'CLIPTORECT') else None
text(p.get('TEXT',''),Point(x=x+2,y=y+2),angle=0,baseline='hanging',frame=frame,wrap_width=max(1,w-4) if flag(p,'WORDWRAP') else None)
if flag(p,'WORDWRAP'): warn('text_frame_metrics','Text frame wrapping uses estimated font advances; clipping is preserved',record)
if flag(p,'COLLAPSED'): warn('collapsed_note','Collapsed note is rendered as a clipped text frame',record)
elif t in (8,9,11,12):
rx=abs(coordinate(p,'RADIUS')); ry=abs(coordinate(p,'SECONDARYRADIUS')) or rx
start=number(p,'STARTANGLE'); end=number(p,'ENDANGLE',360)
if t==8 or abs(end-start)>=359.999 or start==end:
ellipse(pos.x,pos.y,rx,ry,fill if t in (8,9) else 'none')
elif rx and ry:
def pt(deg):
a=math.radians(deg); a=math.atan2(rx*math.sin(a),ry*math.cos(a))
return Point(x=pos.x+rx*math.cos(a),y=pos.y-ry*math.sin(a))
a,b=pt(start),pt(end); sweep=(end-start)%360
d=f'M{a.x:.15g},{a.y:.15g} A{rx:.15g},{ry:.15g} 0 {1 if sweep>180 else 0} 0 {b.x:.15g},{b.y:.15g}'
if t==9: d+=f' L{pos.x},{pos.y} Z'
path(d=d,fill_=fill if t==9 else 'none',box=bounds([Point(x=pos.x-rx,y=pos.y-ry),Point(x=pos.x+rx,y=pos.y+ry)],width))
elif t==2:
bits=integer(p,'PINCONGLOMERATE'); rot=(bits&3)*90
length=coordinate(p,'PINLENGTH')
end=transform_point(Point(x=length,y=0),pos,rot)
path([pos,end],fill_='none')
# Name extends into body, number lies over the pin. Keep labels upright.
horizontal=(bits&3)%2==0
direction=1 if (bits&3) in (0,3) else -1
if bits&8:
at=Point(x=pos.x-(4*direction if horizontal else 0),y=pos.y+(0 if horizontal else -4*direction))
text(p.get('NAME',''),at,angle=0 if horizontal else -90,font=integer(p,'NAME_CUSTOMFONTID',1),anchor='end' if direction>0 else 'start',baseline='middle')
if bits&16:
at=Point(x=(pos.x+end.x)/2-(0 if horizontal else 3),y=(pos.y+end.y)/2-(3 if horizontal else 0))
text(p.get('DESIGNATOR',''),at,angle=0 if horizontal else -90,font=integer(p,'DESIGNATOR_CUSTOMFONTID',1),anchor='middle')
if integer(p,'SYMBOL_OUTEREDGE')==1: ellipse(pos.x,pos.y,2,2)
elif integer(p,'SYMBOL_OUTEREDGE'): warn('pin_decoration','Unsupported outer-edge pin symbol',record)
if integer(p,'SYMBOL_INNEREDGE')==3:
local([(0,-3),(-4,0),(0,3)],rotation=rot,fill_='none')
elif integer(p,'SYMBOL_INNEREDGE'): warn('pin_decoration','Unsupported inner-edge pin symbol',record)
if integer(p,'SYMBOL_OUTER') or integer(p,'SYMBOL_INNER'): warn('pin_decoration','Additional pin decorations are not rendered',record)
if any(k in p for k in ('NAME_CUSTOMPOSITION_MARGIN','DESIGNATOR_CUSTOMPOSITION_MARGIN')) or integer(p,'PINNAME_POSITIONCONGLOMERATE') or integer(p,'PINDESIGNATOR_POSITIONCONGLOMERATE'):
warn('pin_text_position','Custom pin text positions use standard inferred offsets',record)
elif t in (4,25,32,33,34,41):
value=p.get('TEXT','')
if t==34 and component_id:
parent=records.get((record.stream,int(component_id.rsplit(':',1)[-1])))
if parent and integer(parent.properties,'PARTCOUNT')>2:
unit=integer(parent.properties,'CURRENTPARTID',1)
if 1<=unit<=26: value+=chr(64+unit)
else: warn('unit_suffix','Unit suffix above Z is unsupported',record)
if t==41 and flag(p,'SHOWNAME'): value=p.get('NAME','')+': '+value
# Parameter labels use upright text with opposite anchors at orientations 2/3.
if t in (34,41) and integer(p,'ORIENTATION') in (2,3):
text(value,angle=0 if integer(p,'ORIENTATION')==2 else -90,anchor='end',baseline='hanging')
elif t==4 and integer(p,'ORIENTATION')==2:
just=max(0,min(8,integer(p,'JUSTIFICATION')))
text(value,angle=0,anchor=('end','middle','start')[just%3],baseline=('hanging','middle','alphabetic')[just//3])
else: text(value)
elif t==29: ellipse(pos.x,pos.y,1.5,1.5,stroke)
elif t==22:
path([Point(x=pos.x-2,y=pos.y-2),Point(x=pos.x+2,y=pos.y+2)],fill_='none')
path([Point(x=pos.x-2,y=pos.y+2),Point(x=pos.x+2,y=pos.y-2)],fill_='none')
elif t==17:
style=integer(p,'STYLE'); rot=90*integer(p,'ORIENTATION')
if flag(p,'ISCROSSSHEETCONNECTOR'):
local([(8,-3),(4,0),(8,3)],fill_='none');local([(4,-3),(0,0),(4,3)],fill_='none')
elif style in (0,1,2,4):
local([(0,0),(10,0)],fill_='none')
if style==0:
c=transform_point(Point(x=10,y=0),pos,rot);ellipse(c.x,c.y,2.5,2.5)
elif style==1: local([(6,-3),(10,0),(6,3)],fill_='none')
elif style==2: local([(10,-7),(10,7)],fill_='none')
elif style==4:
for dx,span in [(10,7),(13,4),(16,1)]: local([(dx,-span),(dx,span)],fill_='none')
else: warn('power_style',f'Unsupported power port style {style}; marker omitted rather than substituted',record)
if flag(p,'SHOWNETNAME'):
offset = 20 if style==4 else 14 if flag(p,'ISCROSSSHEETCONNECTOR') else 12
at=transform_point(Point(x=offset,y=0),pos,rot)
orient=integer(p,'ORIENTATION')
text(p.get('TEXT',''),at,angle=0,anchor=('start','middle','end','middle')[orient%4],baseline=('middle','alphabetic','middle','hanging')[orient%4])
elif t==18:
style=integer(p,'STYLE'); w=coordinate(p,'WIDTH'); h=coordinate(p,'HEIGHT') or 10
# Style 0..3 horizontal and 4..7 vertical; bit0 left/top, bit1 right/bottom.
tip=min(h/2,w/3); left=bool(style&1); right=bool(style&2)
pts=[(0,0),(tip if left else 0,-h/2),(w-tip if right else w,-h/2),(w,0),(w-tip if right else w,h/2),(tip if left else 0,h/2)]
local(pts,rotation=90 if style>=4 else 0,closed=True,fill_=color(p.get('AREACOLOR','8454143')))
at=transform_point(Point(x=w/2,y=0),pos,90 if style>=4 else 0)
text(p.get('NAME',''),at,angle=-90 if style>=4 else 0,anchor='middle',baseline='middle')
if p.get('HARNESSTYPE'): warn('harness','Harness semantics are not resolved',record)
elif t==16:
parent=records.get((record.stream,integer(p,'OWNERINDEX',-1)))
if parent and integer(parent.properties,'RECORD')==15:
q=parent.properties; origin=point(q);side=integer(p,'SIDE');distance=number(p,'DISTANCEFROMTOP')*10+number(p,'DISTANCEFROMTOP_FRAC1')/100000
at=Point(x=origin.x+(coordinate(q,'XSIZE') if side==1 else distance if side>=2 else 0),y=origin.y+(coordinate(q,'YSIZE') if side==3 else distance if side<2 else 0))
text(p.get('NAME',''),at,anchor='end' if side==1 else 'start',baseline='middle')
path([Point(x=at.x-2,y=at.y),Point(x=at.x+2,y=at.y)],fill_='none')
warn('sheet_entry_shape','Sheet entry location and name are rendered; entry arrow style is unsupported',record)
else: warn('orphan_sheet_entry','Sheet entry owner not found',record)
return output
@@ -0,0 +1,78 @@
"""Bounds-checked property record reader; keeps physical indexes including binary records."""
import re
import base64
import struct
from dataclasses import dataclass
MAX_RECORDS = 100_000
MAX_RECORD_BYTES = 4 * 1024 * 1024
class ParseError(ValueError):
pass
@dataclass
class Record:
index: int
stream: str
properties: dict[str,str]
binary: bool = False
@property
def id(self):
return f'{self.stream}:{self.index}'
def properties(payload: bytes):
result = {}
# UTF8 companion fields take precedence irrespective of source order.
utf8 = {}
for item in payload.rstrip(b'\x00\r\n').split(b'|'):
if b'=' not in item:
continue
k,v = item.split(b'=',1)
key = k.decode('ascii',errors='replace').upper()
if key.startswith('%UTF8%'):
utf8[key[6:]] = v.decode('utf-8',errors='replace')
else:
result[key] = v.decode('cp1252',errors='replace')
result.update(utf8)
return result
def binary_records(data: bytes, stream: str):
offset=0
records=[]
while offset < len(data):
if len(records)>MAX_RECORDS:
raise ParseError('Record count limit exceeded')
if len(data)-offset<4:
if not any(data[offset:]): break
raise ParseError(f'{stream}: truncated length at byte {offset}')
word=struct.unpack_from('<I',data,offset)[0]
length, kind=word&0xffffff, word>>24
if not 0 < length <= MAX_RECORD_BYTES or length>len(data)-offset-4:
raise ParseError(f'{stream}: invalid record length {length} at byte {offset}')
payload=data[offset+4:offset+4+length]
# Header is outside OWNERINDEX numbering; first object is index zero.
records.append(Record(len(records)-1,stream,properties(payload) if kind==0 else {'BINARY_KIND':str(kind),'BINARY_BASE64':base64.b64encode(payload).decode('ascii')},kind!=0))
offset+=4+length
return records
def ascii_records(data: bytes):
lines=data.lstrip(b'\xef\xbb\xbf').splitlines()
records=[]
for line in lines:
if not line.strip(): continue
if len(line)>MAX_RECORD_BYTES: raise ParseError('Text record size limit exceeded')
if len(records)>MAX_RECORDS: raise ParseError('Record count limit exceeded')
p=properties(line)
if not p or not ('HEADER' in p or 'RECORD' in p):
raise ParseError('Malformed ASCII schematic record')
records.append(Record(len(records)-1,'FileHeader',p))
return records
def detect_format(data):
if data.startswith(b'\xd0\xcf\x11\xe0\xa1\xb1\x1a\xe1'): return 'ole'
if re.match(br'\|HEADER=',data.lstrip(b'\xef\xbb\xbf \r\n'),re.I): return 'ascii'
raise ParseError('Not a supported SchDoc: expected OLE container or |HEADER= ASCII schematic')
@@ -0,0 +1,183 @@
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)