اضافه شدن قابلیت مقایسه شماتیک

This commit is contained in:
2026-11-07 15:42:53 +03:30
والد f5da7ee41b
کامیت d2b8ae96c9
28فایلهای تغییر یافته به همراه1012 افزوده شده و 33 حذف شده
+3 -1
مشاهده پرونده
@@ -15,8 +15,10 @@ def cleanup(batch=100):
for job in db.scalars(q):
try:
storage().delete(job.input_key)
if job.input_key.endswith('/comparison.json'):
for side in ('old','new'): storage().delete(f'{job.id}/{side}.SchDoc')
for attempt in (1,2):
for name in ('data.json','drawing.svg'):
for name in ('data.json','drawing.svg','old.svg','new.svg'):
storage().delete(f'{job.id}/attempt-{attempt}/{name}')
db.delete(job); removed+=1
except Exception: log.exception('Expired artifact cleanup failed for %s',job.id)
+108
مشاهده پرونده
@@ -0,0 +1,108 @@
"""Comparison routes using the existing Job, storage, authentication and queue."""
import hashlib
import json
import secrets
import time
from pathlib import Path
from typing import Annotated,Literal
from fastapi import File,Form,Header,UploadFile
from fastapi.responses import FileResponse,JSONResponse,Response
from sqlalchemy import select,func,text
from starlette.concurrency import run_in_threadpool
from .config import settings
from .db import Job,sessions
from .storage import storage
from .uploads import read_schematic
from .models.api import ComparisonStatus,ComparisonUploadResponse
FRONTEND=Path(__file__).resolve().parents[2]/'frontend'
def register_comparison_routes(app, *, authorize, require_api, error):
def comparison(identifier,key,token):
job=authorize(identifier,key,token,comparison=True)
if not job.input_key.endswith('/comparison.json'):error('not_found','Comparison not found',404)
return job
def state(job):
result={'success':job.status!='failed','id':job.id,'comparison_id':job.id,'status':job.status,
'url':settings().public_url+'/compare/'+job.id,'viewer_url':settings().public_url+'/compare/'+job.id,
'status_url':settings().public_url+'/api/v1/comparisons/'+job.id,
'expires_at':job.expires_at,'access':job.access}
if job.summary:result.update(json.loads(job.summary))
if job.error_code:result['error']={'code':job.error_code,'message':job.error_message}
return result
@app.post('/api/compare',status_code=202,response_model=ComparisonUploadResponse,response_model_exclude_unset=True,tags=['Comparisons'])
@app.post('/api/v1/comparisons',status_code=202,response_model=ComparisonUploadResponse,response_model_exclude_unset=True,tags=['Comparisons'])
async def create(old_file:Annotated[UploadFile,File()],new_file:Annotated[UploadFile,File()],
access:Annotated[Literal['private','unlisted'],Form()]='private',
expires_in:Annotated[int|None,Form(gt=0)]=None,
x_api_key:Annotated[str|None,Header()]=None):
require_api(x_api_key)
ttl=expires_in or settings().link_ttl
if ttl>settings().link_ttl:error('invalid_expiry','expires_in exceeds the configured link lifetime',422)
try:
old_name,old=await read_schematic(old_file)
new_name,new=await read_schematic(new_file)
finally:
await old_file.close();await new_file.close()
identifier=secrets.token_hex(24);token=secrets.token_urlsafe(32);now=time.time()
job=Job(id=identifier,filename='Schematic comparison',status='queued',access=access,
token_hash=hashlib.sha256(token.encode()).hexdigest(),created_at=now,updated_at=now,
expires_at=now+ttl,attempt=0,input_key=identifier+'/comparison.json')
files={identifier+'/old.SchDoc':old,identifier+'/new.SchDoc':new,
job.input_key:json.dumps({'schema_version':'1.0','old_filename':old_name,'new_filename':new_name}).encode()}
def persist():
written=[]
try:
with sessions().begin() as db:
if db.bind.dialect.name=='postgresql':db.execute(text('SELECT pg_advisory_xact_lock(84721309)'))
pending=db.scalar(select(func.count()).select_from(Job).where(Job.status.in_(['queued','processing']),Job.expires_at>now))
if pending>=settings().max_pending:error('queue_full','Rendering queue is full; try again later',503)
for key,body in files.items():
try: storage().put(key,body)
except Exception: error('storage_unavailable','Upload storage unavailable',503)
written.append(key)
db.add(job);db.flush()
except Exception:
for key in written:
try:storage().delete(key)
except Exception:pass
raise
try:await run_in_threadpool(persist)
except OSError:error('storage_unavailable','Upload storage unavailable',503)
result=state(job)
if access=='private':
result['viewer_token']=token
result['viewer_url']+='#token='+token
result['url']=result['viewer_url']
return JSONResponse(result,status_code=202,headers={'Location':result['status_url'],'Retry-After':'1'})
@app.get('/api/compare/{identifier}',response_model=ComparisonStatus,response_model_exclude_unset=True,tags=['Comparisons'])
@app.get('/api/v1/comparisons/{identifier}',response_model=ComparisonStatus,response_model_exclude_unset=True,tags=['Comparisons'])
def get(identifier:str,x_api_key:Annotated[str|None,Header()]=None,x_viewer_token:Annotated[str|None,Header()]=None):
job=comparison(identifier,x_api_key,x_viewer_token);result=state(job)
if job.status=='completed':
try:result.update(json.loads(storage().get(job.output_prefix+'/data.json')))
except Exception:error('storage_unavailable','Comparison result unavailable',503)
return result
@app.get('/api/v1/comparisons/{identifier}/svg/{side}',response_class=Response,tags=['Comparisons'])
def svg(identifier:str,side:Literal['old','new'],x_api_key:Annotated[str|None,Header()]=None,
x_viewer_token:Annotated[str|None,Header()]=None):
job=comparison(identifier,x_api_key,x_viewer_token)
if job.status=='failed':error(job.error_code,job.error_message,422)
if job.status!='completed':error('not_ready','Comparison is still in progress',409)
try:body=storage().get(job.output_prefix+'/'+side+'.svg')
except Exception:error('storage_unavailable','Comparison rendering unavailable',503)
return Response(body,media_type='image/svg+xml')
@app.get('/compare',include_in_schema=False)
def upload_page():return FileResponse(FRONTEND/'compare-upload.html')
@app.get('/compare/{identifier}',include_in_schema=False)
def page(identifier:str):
import re
if not re.fullmatch('[a-f0-9]{48}',identifier):error('not_found','Comparison not found',404)
return FileResponse(FRONTEND/'compare.html')
@@ -0,0 +1,32 @@
"""Reuse the parser's resource boundary, geometry model and SVG serializer."""
from . import parse_task # Sets the same subprocess resource limits, without running it.
import json
import sys
from pathlib import Path
from .documents import parse_document
from .diff import compare_documents
from .parser.record_decoder import ParseError
from .render import svg_document
def run():
source,destination,_,identifier=sys.argv[1:]
dest=Path(destination)
try:
manifest=json.loads(Path(source).read_text())
old=parse_document((dest/'old.SchDoc').read_bytes(),manifest['old_filename'],identifier+'-old',allow_empty=True)
new=parse_document((dest/'new.SchDoc').read_bytes(),manifest['new_filename'],identifier+'-new',allow_empty=True)
report=compare_documents(old,new)
(dest/'data.json').write_text(json.dumps(report,ensure_ascii=False),encoding='utf-8')
for side,doc in [('old',old),('new',new)]:
annotations={c[f'{side}_id']:{'id':c['id'],'status':c['status'],'bounds':c[f'{side}_bounds']}
for c in report['changes'] if c[f'{side}_id'] is not None}
(dest/f'{side}.svg').write_bytes(svg_document(doc,annotations,id_prefix=side+'-'))
result={'ok':True,'summary':{'summary':report['summary'],'total_changed':report['total_changed']}}
except (ParseError,ValueError) as exc:
result={'ok':False,'code':'invalid_schematic','message':str(exc)[:500]}
except Exception:
result={'ok':False,'code':'comparison_error','message':'The schematic comparison could not be completed'}
(dest/'result.json').write_text(json.dumps(result),encoding='utf-8')
if __name__=='__main__':run()
+179
مشاهده پرونده
@@ -0,0 +1,179 @@
"""Structural SchDoc comparison. Physical record indexes are never cross-file identity."""
import hashlib
import json
import re
from collections import defaultdict
from dataclasses import dataclass
from .models.schematic import Document
BOOKKEEPING = {'OWNERINDEX','OWNERINDEXADDITIONALLIST','INDEXINSHEET','UNIQUEID',
'GRAPHICALLYLOCKED','LOCKED','READONLYSTATE','_RESOLVED_TEXT'}
X_COORD = re.compile(r'^(LOCATION\.X|CORNER\.X|E?X\d+)$')
Y_COORD = re.compile(r'^(LOCATION\.Y|CORNER\.Y|E?Y\d+)$')
NET_TYPES = {17,18,25}
def digest(value):
return hashlib.sha256(json.dumps(value, sort_keys=True, ensure_ascii=False, separators=(',',':')).encode()).hexdigest()
def normalized(properties, origin):
result = {}
keys = set(properties) | {k[:-5] for k in properties if k.endswith('_FRAC') and (X_COORD.match(k[:-5]) or Y_COORD.match(k[:-5]))}
for key in sorted(keys):
value = properties.get(key, '0')
if key in BOOKKEEPING or (key.endswith('_FRAC') and (X_COORD.match(key[:-5]) or Y_COORD.match(key[:-5]))): continue
if X_COORD.match(key) or Y_COORD.match(key):
try:
offset = origin[0] if X_COORD.match(key) else -origin[1]
result[key] = round(float(value) + float(properties.get(key+'_FRAC',0))/100000 - offset, 8)
except ValueError: result[key] = value
else: result[key] = value
return result
@dataclass
class Entity:
id: str
kind: str
uid: str
reference: str
position: tuple
bounds: dict | None
properties: dict
signature: str
primitive_ids: list
record_type: int
def entities(document):
objects = {o.id:o for o in document.objects}
children = defaultdict(list)
for obj in document.objects:
if obj.component_id: children[obj.component_id].append(obj)
image_hashes = defaultdict(list)
for primitive in document.primitives:
if primitive.kind == 'image': image_hashes[primitive.object_id].append(digest(primitive.image_data))
output = []
for component in document.components:
source = objects.get(component.id)
origin = (component.position.x, component.position.y)
records = []
for obj in children[component.id]:
record = normalized(obj.properties, origin)
if image_hashes[obj.id]: record['_IMAGE_SHA256'] = image_hashes[obj.id]
records.append(record)
records.sort(key=digest)
details = {key:getattr(component,key) for key in ('designator','value','comment','library_reference',
'source_library','description','rotation','mirrored','current_part','parameters')}
details['symbol_records'] = digest(records)
output.append(Entity(component.id,'component',source.properties.get('UNIQUEID','').strip().upper() if source else '',
f'{component.designator.upper()}:{component.current_part}' if component.designator else '',
origin,component.bounds.model_dump() if component.primitive_ids else None, details,digest(details),component.primitive_ids,1))
for obj in document.objects:
if obj.component_id or obj.record_type in {31,39,44,45,46,47,48}: continue
if not obj.visible and obj.record_type != 41: continue
origin = (obj.position.x,obj.position.y)
if 'LOCATION.X' not in obj.properties and 'LOCATION.Y' not in obj.properties and obj.connection_points:
origin = (obj.connection_points[0].x,obj.connection_points[0].y)
raw = obj.properties
if obj.record_type in (26,27) and obj.connection_points:
points = [(p.x,p.y) for p in obj.connection_points]
points = min(points, list(reversed(points)))
origin = points[0]
raw = {k:v for k,v in raw.items() if not re.fullmatch(r'[XY]\d+(?:_FRAC)?',k)}
for i,(x,y) in enumerate(points,1): raw.update({f'X{i}':str(x),f'Y{i}':str(-y)})
properties = normalized(raw, origin)
if image_hashes[obj.id]: properties['_IMAGE_SHA256'] = image_hashes[obj.id]
output.append(Entity(obj.id,obj.kind,obj.properties.get('UNIQUEID','').strip().upper(),'',origin,
obj.bounds.model_dump() if obj.primitive_ids else None,properties,digest(properties),obj.primitive_ids,obj.record_type))
return output
def match_entities(old, new):
left = {e.id:e for e in old}; right = {e.id:e for e in new}
pairs = []; ambiguous = 0
# Stages only pair keys unique on BOTH sides. Coincident/duplicate objects
# are not assigned arbitrary nearest neighbors or physical record indexes.
stages = [
('unique_id', lambda e:(e.kind,e.uid) if e.uid else None),
('designator', lambda e:(e.kind,e.reference) if e.reference else None),
('exact', lambda e:(e.kind,e.signature,e.position)),
('structure', lambda e:(e.kind,e.signature)),
('position', lambda e:(e.kind,e.position) if e.bounds is not None else None),
]
for method,key in stages:
a=defaultdict(list); b=defaultdict(list)
for e in left.values():
k=key(e)
if k is not None:a[k].append(e)
for e in right.values():
k=key(e)
if k is not None:b[k].append(e)
for k,values in a.items():
candidates=b.get(k,[])
if len(values)==len(candidates)==1:
x,y=values[0],candidates[0]
pairs.append((x,y,method));del left[x.id];del right[y.id]
# Identical duplicates are a multiset, so reordering them is not a change.
a=defaultdict(list);b=defaultdict(list)
for e in left.values():a[(e.kind,e.signature,e.position)].append(e)
for e in right.values():b[(e.kind,e.signature,e.position)].append(e)
for key,values in a.items():
for x,y in zip(values,b.get(key,[])):
pairs.append((x,y,'identical_multiset'));del left[x.id];del right[y.id]
a=defaultdict(int); b=defaultdict(int)
for entity in left.values(): a[(entity.kind,entity.signature)]+=1
for entity in right.values(): b[(entity.kind,entity.signature)]+=1
ambiguous=sum(1 for k in a.keys() & b.keys() if a[k]>1 or b[k]>1)
return pairs,list(left.values()),list(right.values()),ambiguous
def compare_documents(old: Document, new: Document):
if old.document_type != 'schematic' or new.document_type != 'schematic':
raise ValueError('Only schematic documents can be compared')
before,after = entities(old),entities(new)
pairs,removed,added,ambiguous = match_entities(before,after)
changes=[]
def change(status,a,b,method='',fields=None):
changes.append({'id':f'change-{len(changes)}','status':status,'kind':(b or a).kind,
'label':(b or a).properties.get('designator') or (b or a).properties.get('TEXT') or (b or a).properties.get('NAME') or (b or a).kind,
'old_id':a.id if a else None,'new_id':b.id if b else None,
'old_bounds':a.bounds if a else None,'new_bounds':b.bounds if b else None,
'old_primitive_ids':a.primitive_ids if a else [],'new_primitive_ids':b.primitive_ids if b else [],
'match':method,'moved':bool(a and b and a.position!=b.position),
'movement':{'dx':b.position[0]-a.position[0],'dy':b.position[1]-a.position[1]} if a and b else None,
'fields':fields or []})
for a,b,method in pairs:
fields=[{'property':key,'old':a.properties.get(key),'new':b.properties.get(key)}
for key in sorted(a.properties.keys()|b.properties.keys()) if a.properties.get(key)!=b.properties.get(key)]
moved=a.position!=b.position
if fields or moved:change('modified' if fields else 'moved',a,b,method,fields)
for a in removed:change('removed',a,None)
for b in added:change('added',None,b)
sheet_fields=[{'property':key,'old':old.sheet.properties.get(key),'new':new.sheet.properties.get(key)}
for key in sorted(old.sheet.properties.keys()|new.sheet.properties.keys())
if key not in BOOKKEEPING and old.sheet.properties.get(key)!=new.sheet.properties.get(key)]
if sheet_fields:
changes.append({'id':f'change-{len(changes)}','status':'modified','kind':'sheet','label':'Sheet settings',
'old_id':old.sheet.id,'new_id':new.sheet.id,'old_bounds':old.sheet.bounds.model_dump(),
'new_bounds':new.sheet.bounds.model_dump(),'old_primitive_ids':[],'new_primitive_ids':[],
'match':'sheet','moved':False,'movement':None,'fields':sheet_fields})
def nets(items):
result=defaultdict(list)
for e in items:
if e.record_type in NET_TYPES:
name=e.properties.get('TEXT',e.properties.get('NAME','')).strip()
if name:result[name].append(e.position)
return {name:sorted(points) for name,points in result.items()}
old_nets,new_nets=nets(before),nets(after)
summary={status:sum(c['status']==status for c in changes) for status in ('added','removed','modified','moved')}
return {'schema_version':'1.0','document_type':'schematic_comparison','summary':summary,'total_changed':len(changes),
'old':{'filename':old.filename,'compatibility':old.metadata.get('compatibility'),'sha256':old.metadata.get('sha256'),'sheet':old.sheet.bounds.model_dump(),'bounds':old.bounds.model_dump(),'diagnostics':[d.model_dump() for d in old.diagnostics]},
'new':{'filename':new.filename,'compatibility':new.metadata.get('compatibility'),'sha256':new.metadata.get('sha256'),'sheet':new.sheet.bounds.model_dump(),'bounds':new.bounds.model_dump(),'diagnostics':[d.model_dump() for d in new.diagnostics]},
'changes':changes,'nets':{'added':sorted(new_nets.keys()-old_nets.keys()),'removed':sorted(old_nets.keys()-new_nets.keys()),
'changed':sorted(name for name in old_nets.keys()&new_nets.keys() if old_nets[name]!=new_nets[name])},
'limitations':['This is a structural/connection-graphics comparison, not a compiled electrical netlist.',
'Ambiguous identities remain added/removed. Geometry/designator fallback matches are heuristic.',
'Unsupported native objects retain the parser compatibility limits.'],
'matching':{'matched':len(pairs),'unmatched_old':len(removed),'unmatched_new':len(added),'ambiguous_groups':ambiguous}}
+3 -3
مشاهده پرونده
@@ -5,11 +5,11 @@ from .parser.schdoc_parser import parse_schdoc
from .parser.record_decoder import ParseError
class DocumentParser(Protocol):
def __call__(self, data: bytes, filename: str, identifier: str) -> Document: ...
def __call__(self, data: bytes, filename: str, identifier: str, **options) -> Document: ...
PARSERS: dict[str, DocumentParser] = {'schematic': parse_schdoc}
def parse_document(data: bytes, filename: str, identifier: str, document_type='schematic') -> Document:
def parse_document(data: bytes, filename: str, identifier: str, document_type='schematic', **options) -> Document:
parser=PARSERS.get(document_type)
if parser is None: raise ParseError('Unsupported document type')
return parser(data,filename,identifier)
return parser(data,filename,identifier,**options)
+13 -17
مشاهده پرونده
@@ -17,11 +17,11 @@ from sqlalchemy.exc import SQLAlchemyError
from starlette.concurrency import run_in_threadpool
from .config import settings
from .db import Job, initialize, sessions
from .parser.record_decoder import ParseError, detect_format
from .storage import storage
from .fonts import fonts, stylesheet
from .models.api import UploadResponse, RenderStatus, ErrorEnvelope
from .models.schematic import Document
from .uploads import read_schematic
FRONTEND = Path(__file__).resolve().parents[2] / 'frontend'
ID = re.compile(r'^[a-f0-9]{48}$')
@@ -54,7 +54,8 @@ class UploadLimit:
def __init__(self, app): self.app = app
async def __call__(self, scope, receive, send):
if scope['type'] != 'http': return await self.app(scope, receive, send)
limit = settings().max_upload_bytes + 1024 * 1024
file_count = 2 if scope['path'] in ('/api/compare','/api/v1/comparisons') else 1
limit = file_count * settings().max_upload_bytes + 1024 * 1024
received = 0
async def bounded_receive():
nonlocal received
@@ -70,7 +71,7 @@ app.add_middleware(UploadLimit)
@app.middleware('http')
async def security(request, call_next):
# Reject unauthorized POST bodies before the multipart parser reads them.
if request.method == 'POST' and request.url.path == '/api/v1/schematics/render':
if request.method == 'POST' and request.url.path in ('/api/v1/schematics/render','/api/compare','/api/v1/comparisons'):
if settings().api_key and not authenticated(request.headers.get('x-api-key')):
return JSONResponse({'success': False, 'error': {'code': 'unauthorized', 'message': 'Provide a valid X-API-Key header'}}, 401)
response = await call_next(request)
@@ -108,10 +109,11 @@ def health():
with sessions()() as db: db.execute(text('SELECT 1'))
return {'status': 'ok'}
def authorize(identifier, key, token):
def authorize(identifier, key, token, *, comparison=False):
if not ID.fullmatch(identifier): error('not_found', 'Schematic not found', 404)
with sessions()() as db: job = db.get(Job, identifier)
if job is None: error('not_found', 'Schematic not found', 404)
if job is None or job.input_key.endswith('/comparison.json') != comparison:
error('not_found', 'Schematic not found', 404)
# Private existence and expiry are not disclosed to unauthenticated callers.
if job.access == 'private' and not (authenticated(key) or (token and hmac.compare_digest(job.token_hash, digest(token)))):
error('not_found', 'Schematic not found', 404)
@@ -135,19 +137,9 @@ async def upload(file: Annotated[UploadFile, File(description='Native binary OLE
expires_in: Annotated[int | None, Form(gt=0, description='Link lifetime in seconds, up to configured maximum')] = None,
x_api_key: Annotated[str | None, Header()] = None):
require_api(x_api_key)
name = (file.filename or '').replace('\\', '/').rsplit('/', 1)[-1]
name = ''.join(c for c in name if c.isprintable())[:255]
if not name.lower().endswith('.schdoc'): error('unsupported_file_type', 'Upload a .SchDoc file', 415)
ttl = expires_in or settings().link_ttl
if ttl > settings().link_ttl: error('invalid_expiry', 'expires_in exceeds the configured link lifetime', 422)
body = bytearray()
try:
while chunk := await file.read(1024 * 1024):
body.extend(chunk)
if len(body) > settings().max_upload_bytes: error('upload_too_large', 'Schematic exceeds configured upload size', 413)
finally: await file.close()
try: detect_format(bytes(body))
except ParseError as exc: error('invalid_schematic', str(exc), 422)
name, body = await read_schematic(file)
identifier = secrets.token_hex(24)
token = secrets.token_urlsafe(32)
now = time.time()
@@ -209,7 +201,7 @@ def index(): return FileResponse(FRONTEND / 'index.html')
@app.get('/assets/{name}', include_in_schema=False)
def asset(name: str):
if name not in {'viewer.js','viewer.css','upload.js','upload.css','camera.js'}: error('not_found', 'Asset not found', 404)
if name not in {'viewer.js','viewer.css','upload.js','upload.css','camera.js','compare.js','compare.css','compare-upload.js','interactions.js'}: error('not_found', 'Asset not found', 404)
return FileResponse(FRONTEND / name)
@app.get('/docs', include_in_schema=False)
@@ -228,3 +220,7 @@ def font_file(name: str):
async def database_error(request, exc):
logging.getLogger(__name__).error('Database request failed', exc_info=exc)
return JSONResponse({'success':False,'error':{'code':'database_unavailable','message':'Database service unavailable'}},status_code=503,headers={'Cache-Control':'no-store'})
# Comparison jobs share the existing queue, access policy and error envelope.
from .compare_api import register_comparison_routes
register_comparison_routes(app, authorize=authorize, require_api=require_api, error=error)
+17
مشاهده پرونده
@@ -28,3 +28,20 @@ class RenderStatus(BaseModel):
class UploadResponse(RenderStatus):
status_url: str
viewer_token: str | None = Field(default=None,description='Read capability returned once for a private upload')
class ComparisonStatus(RenderStatus):
comparison_id: str
url: str
status_url: str
document_type: Literal['schematic_comparison'] | None = None
summary: dict[Literal['added','removed','modified','moved'], int] | None = None
total_changed: int | None = None
changes: list[dict] | None = None
old: dict | None = None
new: dict | None = None
nets: dict | None = None
matching: dict | None = None
limitations: list[str] | None = None
class ComparisonUploadResponse(ComparisonStatus):
viewer_token: str | None = Field(default=None, description='Read capability returned once for a private comparison')
@@ -11,7 +11,7 @@ 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:
def parse_schdoc(data: bytes, filename: str, document_id: str, *, allow_empty=False) -> Document:
fmt,streams,main,extra=read_document(data)
if not main or 'HEADER' not in main[0].properties:
raise ParseError('Missing schematic version header')
@@ -144,7 +144,7 @@ def parse_schdoc(data: bytes, filename: str, document_id: str) -> Document:
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')
if not primitives and not allow_empty: 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)
+16 -3
مشاهده پرونده
@@ -9,7 +9,7 @@ def clean(value): return CONTROL.sub('', str(value))
def node(parent, tag, **attrs):
return SubElement(parent, tag, {k.replace('_', '-'): clean(v) for k, v in attrs.items()})
def svg_document(doc: Document) -> bytes:
def svg_document(doc: Document, annotations=None, id_prefix="") -> bytes:
b = doc.sheet.bounds
root = Element('svg', {'xmlns': 'http://www.w3.org/2000/svg', 'viewBox': f'{b.x} {b.y} {b.width} {b.height}',
'preserveAspectRatio': 'xMidYMid meet', 'role': 'img', 'aria-label': 'Schematic'})
@@ -17,11 +17,14 @@ def svg_document(doc: Document) -> bytes:
defs = node(root, 'defs')
for i, p in enumerate(doc.primitives):
target = root
if annotations is not None:
annotation = annotations.get(p.component_id or p.object_id)
target = node(root, 'g', data_object_id=p.component_id or p.object_id, data_change_id=annotation['id'] if annotation else '')
if p.clip:
clip = node(defs, 'clipPath', id=f'clip-{i}', clipPathUnits='userSpaceOnUse')
clip = node(defs, 'clipPath', id=f'{id_prefix}clip-{i}', clipPathUnits='userSpaceOnUse')
c = p.clip
node(clip, 'rect', x=c.x, y=c.y, width=c.width, height=c.height)
target = node(root, 'g', clip_path=f'url(#clip-{i})')
target = node(target, 'g', clip_path=f'url(#{id_prefix}clip-{i})')
if p.kind == 'path':
n = node(target, 'path', d=p.path, stroke=p.stroke, fill=p.fill,
stroke_width=p.width, stroke_linecap='round', stroke_linejoin='round', fill_rule=p.fill_rule)
@@ -40,4 +43,14 @@ def svg_document(doc: Document) -> bytes:
dominant_baseline=p.baseline, text_decoration='underline' if p.underline else 'none')
n.set('{http://www.w3.org/XML/1998/namespace}space', 'preserve')
n.text = clean(p.text)
if annotations is not None:
colors = {'added':'#16854b','removed':'#cf3545','modified':'#b87b09','moved':'#2676c5'}
overlay = node(root, 'g', pointer_events='none')
for annotation in annotations.values():
b = annotation.get('bounds')
if not b: continue
color = colors[annotation['status']]
node(overlay, 'rect', x=b['x']-2, y=b['y']-2, width=max(4,b['width']+4), height=max(4,b['height']+4),
fill=color, fill_opacity='.09', stroke=color, stroke_width='1.5', vector_effect='non-scaling-stroke',
data_change_id=annotation['id'], data_diff_box='true')
return tostring(root, encoding='utf-8', xml_declaration=True)
+1 -1
مشاهده پرونده
@@ -6,7 +6,7 @@ from functools import lru_cache
from pathlib import Path
from .config import settings
KEY = re.compile(r'^[a-f0-9]{48}/(?:input\.SchDoc|attempt-[12]/(?:data\.json|drawing\.svg))$')
KEY = re.compile(r'^[a-f0-9]{48}/(?:input\.SchDoc|(?:old|new)\.SchDoc|comparison\.json|attempt-[12]/(?:data\.json|drawing\.svg|old\.svg|new\.svg))$')
class Storage:
def __init__(self):
+20
مشاهده پرونده
@@ -0,0 +1,20 @@
"""Shared bounded native schematic upload validation."""
from fastapi import HTTPException
from .config import settings
from .parser.record_decoder import ParseError,detect_format
async def read_schematic(file):
try:
name=(file.filename or '').replace('\\','/').rsplit('/',1)[-1]
name=''.join(c for c in name if c.isprintable())[:255]
if not name.lower().endswith('.schdoc'):
raise HTTPException(415,{'code':'unsupported_file_type','message':'Upload a .SchDoc file'})
body=bytearray()
while chunk:=await file.read(1024*1024):
body.extend(chunk)
if len(body)>settings().max_upload_bytes:
raise HTTPException(413,{'code':'upload_too_large','message':'Schematic exceeds configured upload size'})
try:detect_format(bytes(body))
except ParseError as exc:raise HTTPException(422,{'code':'invalid_schematic','message':str(exc)}) from exc
return name,bytes(body)
finally:await file.close()
+12 -3
مشاهده پرونده
@@ -24,15 +24,24 @@ def process(job):
with tempfile.TemporaryDirectory(prefix='schdoc-') as directory:
root = Path(directory)
source = root / 'input.SchDoc'
source.write_bytes(storage().get(job['input_key']))
comparison = job['input_key'].endswith('/comparison.json')
if comparison:
manifest = json.loads(storage().get(job['input_key']))
source = root / 'comparison.json'
source.write_text(json.dumps(manifest), encoding='utf-8')
for side in ('old','new'):
(root / f'{side}.SchDoc').write_bytes(storage().get(f"{job['id']}/{side}.SchDoc"))
else:
source.write_bytes(storage().get(job['input_key']))
env = {k: v for k, v in os.environ.items() if k in ('PATH', 'LANG', 'LC_ALL', 'PYTHONPATH')}
# Drain no untrusted output into logs. Kill/wait is handled by subprocess.run.
subprocess.run([sys.executable, '-m', 'app.parse_task', str(source), str(root), job['filename'], job['id']],
subprocess.run([sys.executable, '-m', 'app.compare_task' if comparison else 'app.parse_task', str(source), str(root), job['filename'], job['id']],
timeout=config.parse_timeout, check=True, env=env, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
result = json.loads((root / 'result.json').read_text())
if result['ok']:
storage().put(prefix + '/data.json', (root / 'data.json').read_bytes(), 'application/json')
storage().put(prefix + '/drawing.svg', (root / 'drawing.svg').read_bytes(), 'image/svg+xml')
for name in (('old.svg','new.svg') if comparison else ('drawing.svg',)):
storage().put(prefix + '/' + name, (root / name).read_bytes(), 'image/svg+xml')
values = dict(status='completed', output_prefix=prefix, summary=json.dumps(result['summary']))
else:
values = dict(status='failed', error_code=result['code'], error_message=result['message'])