@@ -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)
|
||||
مرجع در شماره جدید
مسدود کردن کاربر