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

This commit is contained in:
2026-11-07 15:42:53 +03:30
والد f5da7ee41b
کامیت d2b8ae96c9
28فایلهای تغییر یافته به همراه1012 افزوده شده و 33 حذف شده
@@ -23,5 +23,8 @@ jobs:
- run: .venv/bin/python scripts/concurrency-smoke.py .runtime/fixtures/Channel.SchDoc - run: .venv/bin/python scripts/concurrency-smoke.py .runtime/fixtures/Channel.SchDoc
env: env:
API_KEY: replace-with-a-long-random-api-key-at-least-32-characters API_KEY: replace-with-a-long-random-api-key-at-least-32-characters
- run: .venv/bin/python scripts/compare-smoke.py .runtime/fixtures/Channel.SchDoc .runtime/fixtures/Channel.SchDoc
env:
API_KEY: replace-with-a-long-random-api-key-at-least-32-characters
- if: always() - if: always()
run: docker compose -p altium-ci down run: docker compose -p altium-ci down
+6 -1
مشاهده پرونده
@@ -1,5 +1,10 @@
{$DOMAIN} { {$DOMAIN} {
request_body { @comparison path /api/compare /api/v1/comparisons
request_body @comparison {
max_size 65MiB
}
@single not path /api/compare /api/v1/comparisons
request_body @single {
max_size 33MB max_size 33MB
} }
reverse_proxy api:8000 reverse_proxy api:8000
+6
مشاهده پرونده
@@ -90,6 +90,12 @@ Error responses use `{"success": false, "error": {"code": "…", "message": "…
`unlisted` is an explicit alternative: anyone with its 192-bit random identifier can read the result until expiry. There is no searchable gallery. Every access is checked on the server. Link lifetime is configurable and the worker removes expired database rows and artifacts in batches. After physical cleanup the endpoint returns 404. An already downloaded document cannot be revoked from a recipient's device. The reference viewer clears itself at expiry. `unlisted` is an explicit alternative: anyone with its 192-bit random identifier can read the result until expiry. There is no searchable gallery. Every access is checked on the server. Link lifetime is configurable and the worker removes expired database rows and artifacts in batches. After physical cleanup the endpoint returns 404. An already downloaded document cannot be revoked from a recipient's device. The reference viewer clears itself at expiry.
## Schematic Diff Viewer
Open `/compare` to upload an original and modified SchDoc, or call `POST /api/v1/comparisons` (alias `/api/compare`) with multipart fields `old_file` and `new_file` and the existing `X-API-Key`. It returns HTTP 202 with a private, expiring `/compare/{id}#token=…` URL. Poll `GET /api/v1/comparisons/{id}` using `X-Viewer-Token` until completed to receive summary counts and native-object differences.
The workspace displays original/modified SVGs side by side with synchronized zoom/pan, optional change boxes, filtering, changes-only mode, navigation and property details. It reuses the native parser, renderer, queue, resource-limited worker and local/S3 storage without new packages or database migrations. Net labels and wire geometry are compared; no compiled electrical equivalence is claimed. See [Schematic Diff documentation](docs/SCHEMATIC_DIFF.md) for matching rules, API examples, deployment limits and tests.
## Storage and configuration ## Storage and configuration
Local files are private, written atomically with generated keys. Client filenames never determine storage paths. For private S3-compatible storage set the same `S3_BUCKET`, `S3_ENDPOINT_URL`, AWS credentials and region on API and workers; no bucket ACL or public URL is created. Prefer workload credentials/IAM and configure bucket encryption/lifecycle at the provider. On a multi-host deployment use S3; a Docker named volume is shared only on one host. Storage/database orphan reconciliation after a machine failure is an operational maintenance task; normal completion, retries and expiry cleanup are implemented. Local files are private, written atomically with generated keys. Client filenames never determine storage paths. For private S3-compatible storage set the same `S3_BUCKET`, `S3_ENDPOINT_URL`, AWS credentials and region on API and workers; no bucket ACL or public URL is created. Prefer workload credentials/IAM and configure bucket encryption/lifecycle at the provider. On a multi-host deployment use S3; a Docker named volume is shared only on one host. Storage/database orphan reconciliation after a machine failure is an operational maintenance task; normal completion, retries and expiry cleanup are implemented.
+3 -1
مشاهده پرونده
@@ -15,8 +15,10 @@ def cleanup(batch=100):
for job in db.scalars(q): for job in db.scalars(q):
try: try:
storage().delete(job.input_key) 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 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}') storage().delete(f'{job.id}/attempt-{attempt}/{name}')
db.delete(job); removed+=1 db.delete(job); removed+=1
except Exception: log.exception('Expired artifact cleanup failed for %s',job.id) 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 from .parser.record_decoder import ParseError
class DocumentParser(Protocol): 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} 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) parser=PARSERS.get(document_type)
if parser is None: raise ParseError('Unsupported 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 starlette.concurrency import run_in_threadpool
from .config import settings from .config import settings
from .db import Job, initialize, sessions from .db import Job, initialize, sessions
from .parser.record_decoder import ParseError, detect_format
from .storage import storage from .storage import storage
from .fonts import fonts, stylesheet from .fonts import fonts, stylesheet
from .models.api import UploadResponse, RenderStatus, ErrorEnvelope from .models.api import UploadResponse, RenderStatus, ErrorEnvelope
from .models.schematic import Document from .models.schematic import Document
from .uploads import read_schematic
FRONTEND = Path(__file__).resolve().parents[2] / 'frontend' FRONTEND = Path(__file__).resolve().parents[2] / 'frontend'
ID = re.compile(r'^[a-f0-9]{48}$') ID = re.compile(r'^[a-f0-9]{48}$')
@@ -54,7 +54,8 @@ class UploadLimit:
def __init__(self, app): self.app = app def __init__(self, app): self.app = app
async def __call__(self, scope, receive, send): async def __call__(self, scope, receive, send):
if scope['type'] != 'http': return await self.app(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 received = 0
async def bounded_receive(): async def bounded_receive():
nonlocal received nonlocal received
@@ -70,7 +71,7 @@ app.add_middleware(UploadLimit)
@app.middleware('http') @app.middleware('http')
async def security(request, call_next): async def security(request, call_next):
# Reject unauthorized POST bodies before the multipart parser reads them. # 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')): 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) return JSONResponse({'success': False, 'error': {'code': 'unauthorized', 'message': 'Provide a valid X-API-Key header'}}, 401)
response = await call_next(request) response = await call_next(request)
@@ -108,10 +109,11 @@ def health():
with sessions()() as db: db.execute(text('SELECT 1')) with sessions()() as db: db.execute(text('SELECT 1'))
return {'status': 'ok'} 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) if not ID.fullmatch(identifier): error('not_found', 'Schematic not found', 404)
with sessions()() as db: job = db.get(Job, identifier) 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. # 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)))): 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) 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, 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): x_api_key: Annotated[str | None, Header()] = None):
require_api(x_api_key) 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 ttl = expires_in or settings().link_ttl
if ttl > settings().link_ttl: error('invalid_expiry', 'expires_in exceeds the configured link lifetime', 422) if ttl > settings().link_ttl: error('invalid_expiry', 'expires_in exceeds the configured link lifetime', 422)
body = bytearray() name, body = await read_schematic(file)
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)
identifier = secrets.token_hex(24) identifier = secrets.token_hex(24)
token = secrets.token_urlsafe(32) token = secrets.token_urlsafe(32)
now = time.time() now = time.time()
@@ -209,7 +201,7 @@ def index(): return FileResponse(FRONTEND / 'index.html')
@app.get('/assets/{name}', include_in_schema=False) @app.get('/assets/{name}', include_in_schema=False)
def asset(name: str): 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) return FileResponse(FRONTEND / name)
@app.get('/docs', include_in_schema=False) @app.get('/docs', include_in_schema=False)
@@ -228,3 +220,7 @@ def font_file(name: str):
async def database_error(request, exc): async def database_error(request, exc):
logging.getLogger(__name__).error('Database request failed', exc_info=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'}) 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): class UploadResponse(RenderStatus):
status_url: str status_url: str
viewer_token: str | None = Field(default=None,description='Read capability returned once for a private upload') 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'] 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) fmt,streams,main,extra=read_document(data)
if not main or 'HEADER' not in main[0].properties: if not main or 'HEADER' not in main[0].properties:
raise ParseError('Missing schematic version header') 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, 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)) visible=False,position=Point(x=0,y=0),bounds=bounds([]),properties=p))
if len(primitives)>250000: raise ParseError('Drawable count limit exceeded') 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={} by_component={}
for shape in primitives: for shape in primitives:
if shape.component_id: by_component.setdefault(shape.component_id,[]).append(shape.bounds) 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): def node(parent, tag, **attrs):
return SubElement(parent, tag, {k.replace('_', '-'): clean(v) for k, v in attrs.items()}) 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 b = doc.sheet.bounds
root = Element('svg', {'xmlns': 'http://www.w3.org/2000/svg', 'viewBox': f'{b.x} {b.y} {b.width} {b.height}', 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'}) 'preserveAspectRatio': 'xMidYMid meet', 'role': 'img', 'aria-label': 'Schematic'})
@@ -17,11 +17,14 @@ def svg_document(doc: Document) -> bytes:
defs = node(root, 'defs') defs = node(root, 'defs')
for i, p in enumerate(doc.primitives): for i, p in enumerate(doc.primitives):
target = root 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: 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 c = p.clip
node(clip, 'rect', x=c.x, y=c.y, width=c.width, height=c.height) 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': if p.kind == 'path':
n = node(target, 'path', d=p.path, stroke=p.stroke, fill=p.fill, 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) 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') dominant_baseline=p.baseline, text_decoration='underline' if p.underline else 'none')
n.set('{http://www.w3.org/XML/1998/namespace}space', 'preserve') n.set('{http://www.w3.org/XML/1998/namespace}space', 'preserve')
n.text = clean(p.text) 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) 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 pathlib import Path
from .config import settings 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: class Storage:
def __init__(self): 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: with tempfile.TemporaryDirectory(prefix='schdoc-') as directory:
root = Path(directory) root = Path(directory)
source = root / 'input.SchDoc' 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')} 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. # 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) timeout=config.parse_timeout, check=True, env=env, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
result = json.loads((root / 'result.json').read_text()) result = json.loads((root / 'result.json').read_text())
if result['ok']: if result['ok']:
storage().put(prefix + '/data.json', (root / 'data.json').read_bytes(), 'application/json') 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'])) values = dict(status='completed', output_prefix=prefix, summary=json.dumps(result['summary']))
else: else:
values = dict(status='failed', error_code=result['code'], error_message=result['message']) values = dict(status='failed', error_code=result['code'], error_message=result['message'])
@@ -0,0 +1,227 @@
import copy
import json
import re
import time
from pathlib import Path
from xml.etree.ElementTree import fromstring
import pytest
from sqlalchemy import update
from app.diff import compare_documents
from app.parser.schdoc_parser import parse_schdoc
from app.render import svg_document
from app.db import Job,claim,sessions
from app.storage import storage
from app.worker import process
from test_platform import client,KEY
def parse(raw,name='test.SchDoc'):return parse_schdoc(raw,name,'test')
def compare(a,b):return compare_documents(parse(a),parse(b))
def create(client,old,new,**fields):
return client.post('/api/compare',headers={'X-API-Key':KEY},
files={'old_file':('old.SchDoc',old),'new_file':('new.SchDoc',new)},data=fields)
def test_identical_and_record_reordering(ascii_schematic):
assert compare(ascii_schematic,ascii_schematic)['total_changed']==0
lines=ascii_schematic.decode().splitlines();records=lines[1:]
order=list(reversed(range(len(records))));mapping={old:new for new,old in enumerate(order)}
shuffled=[re.sub(r'(?i)OwnerIndex=(\d+)',lambda m:f'OwnerIndex={mapping[int(m[1])]}',records[i]) for i in order]
result=compare(ascii_schematic,(lines[0]+'\n'+'\n'.join(shuffled)).encode())
assert result['total_changed']==0,result['changes']
def test_component_properties_and_renumbering_with_native_identity(ascii_schematic):
old=ascii_schematic.replace(b'LibReference=R_Symbol',b'UniqueId=COMPONENT01|LibReference=R_Symbol')
new=old.replace(b'Text=R1',b'Text=R9').replace(b'Text=4K7',b'Text=10K')
result=compare(old,new)
assert result['summary']=={'added':0,'removed':0,'modified':1,'moved':0}
change=result['changes'][0]
assert change['kind']=='component' and change['match']=='unique_id'
assert {'designator','value','parameters'}<={f['property'] for f in change['fields']}
def move_component(raw,dx=20,dy=10):
lines=[]
for line in raw.decode().splitlines():
if '|RECORD=1|' in line or '|OwnerIndex=1|' in line:
line=re.sub(r'(Location|Corner)\.([XY])=(-?\d+)',lambda m:f'{m[1]}.{m[2]}={int(m[3])+(dx if m[2]=="X" else dy)}',line)
lines.append(line)
return ('\n'.join(lines)+'\n').encode()
def test_movement_without_inventing_internal_symbol_changes(ascii_schematic):
result=compare(ascii_schematic,move_component(ascii_schematic))
assert result['summary']=={'added':0,'removed':0,'modified':0,'moved':1},result['changes']
assert result['changes'][0]['movement']=={'dx':20,'dy':-10}
assert not result['changes'][0]['fields']
both=compare(ascii_schematic,move_component(ascii_schematic).replace(b'Text=4K7',b'Text=10K'))
assert both['changes'][0]['moved'] and both['changes'][0]['status']=='modified'
def test_added_removed_component_and_wire(ascii_schematic):
added=ascii_schematic+b'''|RECORD=1|LibReference=C|UniqueId=CAP01|Location.X=300|Location.Y=400|CurrentPartId=1
|RECORD=13|OwnerIndex=9|Location.X=300|Location.Y=400|Corner.X=310|Corner.Y=410
|RECORD=34|OwnerIndex=9|Text=C2|Location.X=300|Location.Y=420
|RECORD=27|UniqueId=WIRE_NEW|LocationCount=2|X1=300|Y1=400|X2=350|Y2=400
'''
result=compare(ascii_schematic,added)
assert result['summary']['added']==2 and result['total_changed']==2
assert {c['kind'] for c in result['changes']}=={'component','wire'}
assert compare(added,ascii_schematic)['summary']['removed']==2
def test_nets_report_labels_not_compiled_connectivity(ascii_schematic):
result=compare(ascii_schematic,ascii_schematic.replace(b'Text=VCC',b'Text=VBAT'))
assert result['summary']['modified']==1
assert result['nets']=={'added':['VBAT'],'removed':['VCC'],'changed':[]}
assert result['changes'][0]['kind']=='net_label'
assert any('compiled' in message for message in result['limitations'])
def test_wire_reversal_is_not_an_electrical_or_visual_change(ascii_schematic):
new=ascii_schematic.replace(b'X1=100|Y1=230|X2=150|Y2=230',b'X1=150|Y1=230|X2=100|Y2=230')
assert compare(ascii_schematic,new)['total_changed']==0
def test_duplicates_are_not_arbitrary_matches(ascii_schematic):
extra=b'|RECORD=27|LocationCount=2|X1=10|Y1=10|X2=20|Y2=10\n'
assert compare(ascii_schematic+extra*2,ascii_schematic+extra*2)['total_changed']==0
result=compare(ascii_schematic+extra*2,ascii_schematic+extra)
assert result['summary']['removed']==1 and result['total_changed']==1
def test_report_does_not_mutate_documents_and_embedded_image_changes(ascii_schematic):
from app.models.schematic import Primitive,Bounds,Point
old=parse(ascii_schematic);new=old.model_copy(deep=True)
before=old.model_dump_json();compare_documents(old,new);assert old.model_dump_json()==before
# Existing parsed image record with a changed binary raster, same filename.
obj=new.objects[-1].model_copy(deep=True);obj.id='image';obj.kind='image';obj.record_type=30
obj.properties={'RECORD':'30','FILENAME':'logo.bmp','LOCATION.X':'0','LOCATION.Y':'0'};obj.primitive_ids=['image:p'];obj.component_id=None
old.objects.append(obj);new.objects.append(obj.model_copy(deep=True))
p=Primitive(id='image:p',kind='image',object_id='image',bounds=Bounds(x=0,y=0,width=10,height=10),image_data='data:image/png;base64,OLD')
old.primitives.append(p);new.primitives.append(p.model_copy(update={'image_data':'data:image/png;base64,NEW'}))
assert compare_documents(old,new)['summary']['modified']==1
def test_highlight_svg_preserves_geometry_and_distinct_clip_ids(ascii_schematic):
raw=ascii_schematic+b'|RECORD=28|Location.X=1|Corner.X=20|Corner.Y=20|ClipToRect=T|Text=note\n'
doc=parse(raw);item=doc.objects[-1]
annotations={item.id:{'id':'change-0','status':'modified','bounds':item.bounds.model_dump()}}
svg=svg_document(doc,annotations,id_prefix='old-');tree=fromstring(svg)
assert b'data-diff-box="true"' in svg and b'data-change-id="change-0"' in svg
assert b'old-clip-' in svg and b'url(#old-clip-' in svg
assert b'M100,-230 L150,-230' in svg
assert not list(tree.iter('{http://www.w3.org/2000/svg}script'))
def test_comparison_api_worker_private_results_and_cleanup(client,ascii_schematic):
response=create(client,ascii_schematic,ascii_schematic.replace(b'Text=4K7',b'Text=10K'))
assert response.status_code==202,response.text
created=response.json();identifier=created['comparison_id'];headers={'X-Viewer-Token':created['viewer_token']}
assert '#token=' in created['url'] and created['status']=='queued'
base='/api/v1/comparisons/'+identifier
assert client.get(base).status_code==404
assert client.get(base,headers={'X-Viewer-Token':'bad'}).status_code==404
assert client.get(base+'/svg/old',headers=headers).status_code==409
job=claim();process(job)
response=client.get(base,headers=headers);result=response.json()
assert result['status']=='completed',result
assert result['summary']['modified']==1
assert client.get('/api/compare/'+identifier,headers=headers).json()['changes']==result['changes']
for side in ('old','new'):
r=client.get(base+'/svg/'+side,headers=headers)
assert r.status_code==200 and b'data-diff-box' in r.content
assert client.get('/compare/'+identifier).status_code==200
assert client.get('/compare').status_code==200
for asset in ['compare.js','compare.css','interactions.js','compare-upload.js']:
assert client.get('/assets/'+asset).status_code==200
with sessions().begin() as db:db.execute(update(Job).where(Job.id==identifier).values(expires_at=time.time()-1))
assert client.get(base,headers=headers).status_code==410
from app.cleanup import cleanup
assert cleanup()==1
with pytest.raises(FileNotFoundError):storage().get(identifier+'/old.SchDoc')
def test_comparison_validation_auth_and_queue_limit(client,ascii_schematic,monkeypatch):
assert client.post('/api/compare').status_code==401
assert client.post('/api/v1/comparisons').status_code==401
assert create(client,ascii_schematic,b'garbage').status_code==422
assert create(client,ascii_schematic,ascii_schematic,expires_in=999999999).status_code==422
assert create(client,b'x'*(1048576+1),ascii_schematic).status_code==413
monkeypatch.setenv('MAX_PENDING_JOBS','1')
from app.config import settings
settings.cache_clear()
assert create(client,ascii_schematic,ascii_schematic).status_code==202
assert create(client,ascii_schematic,ascii_schematic).status_code==503
def test_corrupt_second_file_fails_durably_and_unlisted(client,ascii_schematic):
response=create(client,ascii_schematic,b'\xd0\xcf\x11\xe0\xa1\xb1\x1a\xe1'+b'\0'*504,access='unlisted')
assert response.status_code==202
process(claim())
result=client.get('/api/compare/'+response.json()['id']).json()
assert result['status']=='failed' and result['error']['code']=='invalid_schematic'
def test_comparison_cross_origin_preflight_and_creation(client,ascii_schematic):
origin='https://git.sadentel.net'
r=client.options('/api/compare',headers={'Origin':origin,'Access-Control-Request-Method':'POST','Access-Control-Request-Headers':'x-api-key'})
assert r.status_code==200 and r.headers['access-control-allow-origin']==origin
r=client.post('/api/compare',headers={'Origin':origin})
assert r.status_code==401 and r.headers['access-control-allow-origin']==origin
def test_partial_storage_failure_leaves_no_comparison_job(client,ascii_schematic,monkeypatch):
original=storage().put;calls=[]
def failing(key,body,*args,**kw):
calls.append(key)
if len(calls)==2:raise RuntimeError('S3 unavailable')
return original(key,body,*args,**kw)
monkeypatch.setattr(storage(),'put',failing)
assert create(client,ascii_schematic,ascii_schematic).status_code==503
assert claim() is None
assert not storage().path(calls[0]).exists()
def test_native_ole_comparison_preserves_original_geometry(client):
fixture=Path(__file__).resolve().parents[2]/'.runtime/fixtures/Channel.SchDoc'
if not fixture.is_file():pytest.skip('Fetch the native fixture')
response=create(client,fixture.read_bytes(),fixture.read_bytes());assert response.status_code==202
process(claim())
identifier=response.json()['id'];headers={'X-Viewer-Token':response.json()['viewer_token']}
result=client.get('/api/v1/comparisons/'+identifier,headers=headers).json()
assert result['status']=='completed',result
assert result['total_changed']==0
assert result['old']['sha256']==result['new']['sha256']
assert b'data-object-id' in client.get(f'/api/v1/comparisons/{identifier}/svg/old',headers=headers).content
def test_comparison_request_body_is_bounded(client):
response=client.post('/api/compare',content=b'x'*(4*1048576),
headers={'X-API-Key':KEY,'Content-Type':'multipart/form-data; boundary=foo'})
assert response.status_code==413
def test_comparison_with_valid_empty_sheet_keeps_single_parser_behavior(client,ascii_schematic):
from app.parser.record_decoder import ParseError
empty=b'|HEADER=Protel for Windows - Schematic Capture Ascii File Version 5.0\n|RECORD=31|FontIdCount=1|Size1=10|FontName1=Arial\n'
with pytest.raises(ParseError,match='No supported visible geometry'):parse(empty)
response=create(client,ascii_schematic,empty);assert response.status_code==202
process(claim());headers={'X-Viewer-Token':response.json()['viewer_token']}
result=client.get('/api/compare/'+response.json()['id'],headers=headers).json()
assert result['status']=='completed',result
assert result['summary']['removed']==3
assert result['summary']['added']==0
svg=client.get('/api/v1/comparisons/'+response.json()['id']+'/svg/new',headers=headers)
assert svg.status_code==200 and b'<rect' in svg.content
def test_comparison_ids_do_not_become_single_schematic_resources(client,ascii_schematic):
response=create(client,ascii_schematic,ascii_schematic)
identifier=response.json()['id'];headers={'X-Viewer-Token':response.json()['viewer_token']}
for endpoint in ('status','data','svg'):
assert client.get(f'/api/v1/schematics/{identifier}/{endpoint}',headers=headers).status_code==404
from test_platform import upload
single=upload(client,ascii_schematic).json()
assert client.get('/api/compare/'+single['id'],headers={'X-Viewer-Token':single['viewer_token']}).status_code==404
+4
مشاهده پرونده
@@ -41,3 +41,7 @@ Error code examples:
| 503 | queue_full / storage_unavailable / database_unavailable | | 503 | queue_full / storage_unavailable / database_unavailable |
A worker crash is retried after its lease expires, at most twice total. A parse failure is terminal for that upload. No automatic retry of side-effecting API requests is performed. Data and SVG reads are immutable after a successful attempt; access/expiry remain server-authoritative on every read. HTTP caches are disabled to protect private drawings. A worker crash is retried after its lease expires, at most twice total. A parse failure is terminal for that upload. No automatic retry of side-effecting API requests is performed. Data and SVG reads are immutable after a successful attempt; access/expiry remain server-authoritative on every read. HTTP caches are disabled to protect private drawings.
## Schematic comparisons
`POST /api/v1/comparisons` (alias `/api/compare`) accepts `old_file` and `new_file`, with the same API key, private tokens, expiry, durable queue and error conventions. `GET /api/v1/comparisons/{id}` (alias `/api/compare/{id}`) returns status and the completed structural diff. `/compare/{id}` opens the side-by-side workspace. See [the comparison contract](SCHEMATIC_DIFF.md).
+87
مشاهده پرونده
@@ -0,0 +1,87 @@
# Schematic Diff Viewer
The comparison feature reuses native SchDoc parsing, the original vector geometry, the SVG serializer, the existing PostgreSQL/SQLite durable job queue, bounded parser subprocesses, local/S3 storage, API-key authentication and expiring viewer capabilities. It adds no runtime or frontend packages and no database columns/tables/migration.
Open `/compare` to select an original and modified file, or follow the comparison link on the main upload page. After processing, `/compare/{id}#token=…` shows the original on the left and modified document on the right. Original schematic primitives keep their colors and geometry; optional boxes annotate changes. Added objects are green, removed red, modified amber and moved blue. A changes list provides property details, previous/next navigation and click-to-focus. Zoom/pan/pinch, synchronized world coordinates, fit, category filtering, changes-only mode and highlight toggling are available. Both drawings have unique SVG clip identifiers to avoid collisions in the same DOM.
The single-file viewer and its fullscreen interface remain unchanged. The new comparison page is a separate workspace. Existing iframe policy remains limited to `/view/*`; `/compare/*` is a standalone page and is not additionally granted cross-origin framing permission by this feature.
## API
Primary versioned endpoint: `POST /api/v1/comparisons`. `POST /api/compare` is an alias for the same implementation. Upload authentication is the existing `X-API-Key` header.
```bash
curl -X POST http://127.0.0.1:8000/api/compare \
-H "X-API-Key: $API_KEY" \
-F 'old_file=@original.SchDoc' \
-F 'new_file=@modified.SchDoc' \
-F 'access=private' \
-F 'expires_in=3600'
```
Response: HTTP **202**, matching the existing asynchronous rendering convention:
```json
{
"success": true,
"id": "96a2db531ed846e79be507eec36e00ba4c09a142d67ab35d",
"comparison_id": "96a2db531ed846e79be507eec36e00ba4c09a142d67ab35d",
"status": "queued",
"url": "http://127.0.0.1:8000/compare/96a2db531ed846e79be507eec36e00ba4c09a142d67ab35d#token=READ_TOKEN",
"viewer_url": "http://127.0.0.1:8000/compare/96a2db531ed846e79be507eec36e00ba4c09a142d67ab35d#token=READ_TOKEN",
"viewer_token": "READ_TOKEN",
"status_url": "http://127.0.0.1:8000/api/v1/comparisons/96a2db531ed846e79be507eec36e00ba4c09a142d67ab35d",
"access": "private",
"expires_at": 1794040000
}
```
Retain the initial URL including its token fragment. Opening it immediately is supported: the workspace polls until processing completes. `summary` is available only when the worker finishes; a queued upload cannot truthfully report change counts yet.
Poll `GET /api/v1/comparisons/{id}` (alias `GET /api/compare/{id}`) with `X-Viewer-Token: READ_TOKEN` or the existing service API key. The states and JSON error envelope follow the rendering API: queued, processing, completed, failed. A malformed file may pass initial envelope identification and fail later in the native parser. Missing/bad private tokens receive 404; expired authorized links receive 410 until cleanup. `access=unlisted` is an explicit alternative with the same unpredictable identifiers as the single viewer.
Completed responses include:
- `summary`: mutually exclusive primary counts `added`, `removed`, `modified`, `moved`; their sum equals `total_changed`.
- `changes`: status, kind, label, old/new source object IDs, old/new bounds and primitive IDs, matching method, property differences and movement delta of the stored anchor in document units. When geometry also changes, an anchor delta does not assert rigid translation of the entire object. An object that changes properties and moves has primary status `modified` plus `moved: true` and `movement`; it is not counted twice.
- `old` and `new`: filenames, source SHA-256, original sheet/content bounds and parser diagnostics. Initial camera fit uses combined content bounds, while keeping both panes in the same native coordinate system.
- `nets`: added/removed observed net names, and names whose label/port anchor sets changed. A rename is also a modified label with old/new TEXT/NAME in its field changes.
- `matching`: counts of paired/unpaired objects and ambiguous remaining signature groups.
- `limitations`: explicit scope and uncertainty notes.
`GET /api/v1/comparisons/{id}/svg/old` and `/svg/new` return annotated native SVGs with the same read permission checks. Pending SVGs return 409; failed SVGs 422. Existing single-schematic endpoints remain unchanged; comparison clients should use the comparison endpoints and initial comparison URL.
## Structural matching and interpretation
Objects owned by a component are aggregated into one component change rather than counting every pin, symbol segment and designator as independent additions. Component-owned geometry is compared relative to its original placement, so translating a component and its children does not invent internal symbol changes. Properties, parameters, values, designators, rotations, mirrored state, part selection and symbol-record signatures are compared. Embedded raster changes are detected by payload hashes even when filenames match. Unowned wires, labels, ports, junctions, shapes and other supported/source objects are compared separately; sheet properties are compared as sheet settings.
Matching stages are: unique native `UNIQUEID` within the object kind, unique component designator/unit, exact structural signature and position, unique translation-independent structure, then unique kind/position. Only keys unique on both sides are paired at each heuristic stage. Identical repeated objects are compared as multisets. Physical FileHeader/Additional record indexes never serve as persistent cross-file identity, and changes in record order do not create changes by themselves. Ownership/index bookkeeping is ignored. Fractional coordinates are preserved. Wire vertex reversal is normalized as the same undirected wire geometry.
Fallback matching is necessarily heuristic when stable IDs are absent or regenerated: an object replaced at the same position may look like a modification. Ambiguous objects remain added/removed instead of receiving arbitrary nearest-neighbor matches. Every pair records its matching method. Changes to unsupported objects can be identified from retained properties, but cannot be faithfully highlighted if the parser has no drawable geometry. Such changes remain in the list with no fabricated bounding box.
This is **not an Altium compiled netlist comparison**. Wire reroutes, pin positions, net labels and connection anchors can reveal graphic changes, but do not prove an electrical connection was added/removed or changed. Bus expansion, harness routing, repeated channels and cross-sheet/project-level connectivity are not resolved. Do not use these graphics as proof of electrical equivalence. Parser/font/format fidelity limitations from [compatibility](COMPATIBILITY.md) still apply independently to both sides. No pixel-difference engine or generated substitute symbol library is involved.
## Queue, storage and deployment
A comparison occupies one existing `Job` row and one queue slot. Its application-generated input key is `{id}/comparison.json`, an immutable manifest with the two sanitized filenames; the actual sources are `{id}/old.SchDoc` and `{id}/new.SchDoc`. The worker uses that key type to dispatch `app.compare_task` through the same process, resource limits, retry lease and stale-attempt fencing. Successful attempts write `data.json`, `old.svg` and `new.svg` into the existing attempt-scoped storage prefix. No runtime data is embedded in the frontend. Expiry cleanup removes both sources, the manifest and attempt artifacts. Local and S3 paths use the same strict key validation. Multi-file upload failures clean up already-written inputs before a job is admitted.
Comparison parsing explicitly permits valid empty sheets so removing all objects can be compared. The single-file parser retains its existing empty-geometry rejection by default. Each file retains `MAX_UPLOAD_BYTES` (default 32 MiB). Only the two comparison POST paths get a combined multipart limit of `2 * MAX_UPLOAD_BYTES + 1 MiB`; single-file requests keep their original bound. The Caddy example keeps its existing general request limit and adds 65 MiB for the two comparison paths. Adjust external CDN/proxy upload caps correspondingly when deploying larger comparisons. This changes request size handling, not CSP/CORS policy. The same total CPU/memory/wall-clock limits apply per comparison job, so extremely complex pairs may hit resource limits even if each file renders separately.
Restart API and workers after updating. With Compose:
```bash
docker compose up -d --build api worker
```
If using the production Caddy overlay, reload/recreate its proxy to pick up the route-specific multipart limit. A schema migration is not needed.
## Validation
```bash
SCHDOC_SAMPLE='/path/to/real.SchDoc' ./scripts/check.sh
API_KEY="$API_KEY" .venv/bin/python scripts/compare-smoke.py original.SchDoc modified.SchDoc
```
Tests cover record reordering, component UID matching/renumbering, values, pure movement, combined movement/property edits, additions/removals, reversed wire vertices, duplicate objects, labels/net-name scope, embedded raster replacement, nonmutation, original SVG geometry, clip-ID separation, upload/authentication/CORS, per-file/combined limits, partial storage failures, a real OLE same-file comparison, private result/SVG access, failed processing and expiry cleanup. Frontend camera adapter tests cover pane offsets, synchronized world cameras, pinch, change focus and detached listeners. Synthetic unit circuits never appear as application data.
The actual local `Sheet1-before.SchDoc` / `Sheet1-after.SchDoc` pair (205/206 source records) produced 1 moved component, 2 modified wires and 1 added junction. The 1,942-record CPU sheet compared with itself produced zero changes. These are structural integration checks, not a trusted Altium visual comparison or browser UI acceptance test. No proprietary samples are redistributed. Browser interaction and visual validation require a connected browser; absence of browser QA must be reported rather than replaced by camera unit tests.
@@ -0,0 +1,2 @@
<!doctype html>
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Compare Altium schematics</title><link rel="stylesheet" href="/assets/upload.css"><script type="module" src="/assets/compare-upload.js"></script></head><body><main><p class="eyebrow">NATIVE ALTIUM · STRUCTURAL DIFF</p><h1>Compare schematics.</h1><p>Select the original and modified SchDoc files.</p><form id="compare-upload"><label for="key">API key</label><input id="key" type="password" autocomplete="off" placeholder="Configured service key"><label for="old-file">Original schematic</label><input id="old-file" type="file" accept=".SchDoc" required><label for="new-file">Modified schematic</label><input id="new-file" type="file" accept=".SchDoc" required><button type="submit">Compare schematics <span aria-hidden="true">↗</span></button></form><output id="result" role="status"></output><p class="note">Comparison uses native objects and saved geometry. Connection graphics are compared without a compiled electrical netlist.</p><a href="/">Single schematic viewer</a></main></body></html>
@@ -0,0 +1,12 @@
const form=document.querySelector('#compare-upload'),result=document.querySelector('#result');
form.addEventListener('submit',async e=>{
e.preventDefault();const button=form.querySelector('button');button.disabled=true;result.textContent='Uploading both schematics…';
const headers={},key=document.querySelector('#key').value;
if(key)headers['X-API-Key']=key;
const body=new FormData();body.append('old_file',document.querySelector('#old-file').files[0]);body.append('new_file',document.querySelector('#new-file').files[0]);
try {
const response=await fetch('/api/v1/comparisons',{method:'POST',headers,body});const data=await response.json();
if(!response.ok)throw new Error(data.error?.message||'Comparison upload failed');
result.textContent='Queued. ';const link=document.createElement('a');link.href=data.url;link.textContent='Open comparison';result.append(link);
}catch(error){result.textContent=error.message;}finally{button.disabled=false;}
});
+34
مشاهده پرونده
@@ -0,0 +1,34 @@
body { display: flex; flex-direction: column; font-family: system-ui,sans-serif; color: #233431; background: #f4f6f5; }
* { box-sizing: border-box; }
header { display: flex; align-items: center; flex-wrap: wrap; gap: 14px; padding: 12px 16px; border-bottom: 1px solid #d6dfda; }
header > a { color: #244d49; font-size: 15px; font-weight: 650; text-decoration: none; }
#summary, .controls { display: flex; align-items: center; flex-wrap: wrap; gap: 12px; font-size: 12px; }
.controls { margin-left: auto; }
label { display: inline-flex; gap: 4px; align-items: center; }
button, select { border: 1px solid #cbd5ce; border-radius: 4px; padding: 5px 9px; background: white; color: inherit; font: inherit; cursor: pointer; }
button:focus-visible, select:focus-visible, .surface:focus-visible { outline: 2px solid #2676c5; outline-offset: -2px; }
#scope { margin: 0; padding: 7px 16px; font-size: 11px; color: #60716b; border-bottom: 1px solid #d6dfda; }
.workspace { flex: 1; min-height: 0; display: grid; grid-template-columns: minmax(0,1fr) minmax(0,1fr) 270px; }
.pane { min-width: 0; min-height: 0; display: flex; flex-direction: column; border-right: 1px solid #d6dfda; }
h2 { margin: 0; padding: 9px 12px; font-size: 12px; font-weight: 650; background: #edf1ee; display: flex; gap: 10px; }
h2 span { overflow: hidden; white-space: nowrap; text-overflow: ellipsis; font-weight: normal; color: #61746b; }
.surface { flex: 1; min-height: 0; width: 100%; overflow: hidden; background: white; touch-action: none; user-select: none; cursor: grab; }
.surface:active { cursor: grabbing; }
.surface > svg { display: block; width: 100%; height: 100%; }
aside { display: flex; flex-direction: column; min-height: 0; overflow: hidden; background: #fafcfb; }
.change-controls { display: flex; align-items: center; gap: 6px; padding: 10px; font-size: 12px; }
.change-controls label { margin-right: auto; }
#changes { flex: 1; min-height: 0; overflow: auto; padding: 0 8px; }
#changes button { display: flex; text-align: left; align-items: center; gap: 8px; width: 100%; margin-bottom: 5px; border-left: 3px solid currentColor; font-size: 12px; padding: 10px; }
#changes button small { margin-left: auto; color: #64756d; font-size: 10px; }
#changes button[aria-current="true"] { background: #e5eee9; box-shadow: inset 0 0 0 1px #8daba0; }
.added { color: #16854b; }.removed { color: #cf3545; }.modified { color: #b87b09; }.moved { color: #2676c5; }
#details { max-height: 35%; overflow: auto; margin: 0; padding: 10px; white-space: pre-wrap; overflow-wrap: anywhere; border-top: 1px solid #d6dfda; font-size: 11px; }
#details:empty { display: none; }
[data-diff-hidden] { display: none; }
.no-highlights [data-diff-box] { display: none; }
[data-diff-box][data-selected] { stroke-width: 3; fill-opacity: .18; }
@media (max-width: 900px) { .workspace { grid-template-columns: minmax(0,1fr) minmax(0,1fr); grid-template-rows: minmax(0,1fr) 180px; } aside { grid-column: 1/-1; } #details { max-height: 40%; } }
@media (max-width: 550px) { .workspace { grid-template-columns: 1fr; grid-template-rows: minmax(0,1fr) minmax(0,1fr) 140px; } aside { grid-column: auto; } header { gap: 8px; }.controls { margin-left: 0; } }
#changes button[hidden] { display: none; }
+6
مشاهده پرونده
@@ -0,0 +1,6 @@
<!doctype html>
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Schematic comparison</title><link rel="stylesheet" href="/fonts.css"><link rel="stylesheet" href="/assets/viewer.css"><link rel="stylesheet" href="/assets/compare.css"><script type="module" src="/assets/compare.js"></script></head><body>
<header><a href="/compare">Schematic Diff</a><div id="summary" aria-live="polite"></div><div class="controls"><label><input id="sync" type="checkbox" checked> Sync views</label><label><input id="highlights" type="checkbox" checked> Highlights</label><label><input id="only-changes" type="checkbox"> Changes only</label><button id="fit" type="button">Fit</button></div></header>
<p id="scope">Native object comparison · Connection graphics, not a compiled electrical netlist.</p>
<main class="workspace"><section class="pane"><h2>Original <span id="old-name"></span></h2><div id="old-surface" class="surface" tabindex="0" aria-label="Original schematic"></div></section><section class="pane"><h2>Modified <span id="new-name"></span></h2><div id="new-surface" class="surface" tabindex="0" aria-label="Modified schematic"></div></section><aside><div class="change-controls"><label for="filter">Changes</label><select id="filter"><option value="all">All</option><option value="added">Added</option><option value="removed">Removed</option><option value="modified">Modified</option><option value="moved">Moved</option></select><button id="previous" aria-label="Previous change">↑</button><button id="next" aria-label="Next change">↓</button></div><nav id="changes" aria-label="Schematic changes"></nav><pre id="details" aria-live="polite"></pre></aside></main>
<output id="state" class="loading" role="status">Loading comparison…</output></body></html>
+87
مشاهده پرونده
@@ -0,0 +1,87 @@
import { bindCamera } from './interactions.js';
const state=document.querySelector('#state'),id=location.pathname.split('/').pop();
const token=new URLSearchParams(location.hash.slice(1)).get('token')||sessionStorage.getItem(`comparison:${id}`);
if(token)sessionStorage.setItem(`comparison:${id}`,token);
if(location.hash)history.replaceState(null,'',location.pathname);
const headers=token?{'X-Viewer-Token':token}:{},base=`/api/v1/comparisons/${id}`;
const controller=new AbortController(),views=[];
addEventListener('pagehide',()=>{controller.abort();for(const view of views)view.destroy();},{once:true});
async function request(path='',text=false){
const response=await fetch(base+path,{headers,signal:controller.signal,cache:'no-store'});
if(!response.ok){const body=await response.json();throw new Error(body.error?.message||'Comparison unavailable');}
return text?response.text():response.json();
}
function union(boxes){
const x=Math.min(...boxes.map(b=>b.x)),y=Math.min(...boxes.map(b=>b.y));
return {x,y,width:Math.max(...boxes.map(b=>b.x+b.width))-x,height:Math.max(...boxes.map(b=>b.y+b.height))-y};
}
async function start(){
let result;
for(;;){
result=await request();if(result.status==='completed')break;
if(result.status==='failed')throw new Error(result.error?.message||'Comparison failed');
await new Promise(resolve=>setTimeout(resolve,1000));
}
const texts=await Promise.all(['old','new'].map(side=>request('/svg/'+side,true)));
const svgs=texts.map(text=>{
const parsed=new DOMParser().parseFromString(text,'image/svg+xml');
if(parsed.querySelector('parsererror')||parsed.documentElement.localName!=='svg')throw new Error('Invalid rendering artifact');
return document.importNode(parsed.documentElement,true);
});
const content=[result.old.bounds,result.new.bounds].filter(b=>b&&b.width>0&&b.height>0);
const bounds=union(content.length?content:[result.old.sheet,result.new.sheet]);
for(const [index,side] of ['old','new'].entries()){
document.querySelector(`#${side}-name`).textContent=result[side].filename;
const surface=document.querySelector(`#${side}-surface`);surface.append(svgs[index]);
surface.style.backgroundColor=svgs[index].querySelector('rect')?.getAttribute('fill')||'#ffffff';
views.push(bindCamera(surface,svgs[index],bounds,source=>{
if(document.querySelector('#sync').checked)for(const other of views)if(other!==source)other.apply(source.pose());
}));
}
await document.fonts.ready;
state.classList.remove('loading');state.textContent='';
const changes=result.changes,byId=new Map(changes.map(c=>[c.id,c])),rows=new Map(),nodes=svgs.flatMap(svg=>[...svg.querySelectorAll('[data-change-id]')]);
const list=document.querySelector('#changes'),filter=document.querySelector('#filter');
let selected=null;
for(const [status,count]of Object.entries(result.summary)){
const badge=document.createElement('span');badge.className=status;badge.textContent=`${count} ${status}`;document.querySelector('#summary').append(badge);
}
if(!changes.length)list.textContent='No structural changes detected.';
function visibleChanges(){return changes.filter(c=>filter.value==='all'||c.status===filter.value);}
function select(change){
selected=change.id;
for(const [id,row]of rows)row.setAttribute('aria-current',String(id===selected));
for(const node of nodes){if(node.dataset.changeId===selected)node.setAttribute('data-selected','');else node.removeAttribute('data-selected');}
const boxes=[change.old_bounds,change.new_bounds].filter(Boolean);
if(boxes.length)for(const view of views)view.focus(union(boxes));
const details={kind:change.kind,match:change.match||'unmatched'};
if(change.moved)details.movement=change.movement;
if(change.fields.length)details.fields=change.fields;
document.querySelector('#details').textContent=JSON.stringify(details,null,2);
rows.get(selected)?.scrollIntoView({block:'nearest'});
}
for(const change of changes){
const button=document.createElement('button');button.type='button';button.className=change.status;
const label=document.createElement('span');label.textContent=change.label;
const kind=document.createElement('small');kind.textContent=change.status+(change.moved&&change.status==='modified'?', moved':'')+' · '+change.kind;
button.append(label,kind);button.addEventListener('click',()=>select(change));list.append(button);rows.set(change.id,button);
}
function applyFilters(){
for(const node of nodes){
const change=byId.get(node.dataset.changeId);
const hidden=change?(filter.value!=='all'&&change.status!==filter.value):(filter.value!=='all'||document.querySelector('#only-changes').checked);
node.toggleAttribute('data-diff-hidden',hidden);
}
for(const change of changes)rows.get(change.id).hidden=filter.value!=='all'&&change.status!==filter.value;
document.body.classList.toggle('no-highlights',!document.querySelector('#highlights').checked);
}
for(const element of [filter,document.querySelector('#only-changes'),document.querySelector('#highlights')])element.addEventListener('change',applyFilters);
document.querySelector('#fit').addEventListener('click',()=>{for(const view of views)view.fit();});
document.querySelector('#sync').addEventListener('change',()=>{if(document.querySelector('#sync').checked&&views[0])views[1].apply(views[0].pose());});
function next(delta){const candidates=visibleChanges();if(!candidates.length)return;const index=candidates.findIndex(c=>c.id===selected);select(candidates[(index+delta+candidates.length)%candidates.length]);}
document.querySelector('#previous').addEventListener('click',()=>next(-1));document.querySelector('#next').addEventListener('click',()=>next(1));
for(const svg of svgs)svg.addEventListener('click',event=>{const id=event.target.closest('[data-change-id]')?.dataset.changeId;if(byId.has(id))select(byId.get(id));});
applyFilters();
function expire(){const remaining=result.expires_at*1000-Date.now();if(remaining<=0){for(const view of views)view.destroy();document.querySelector('.workspace').replaceChildren();state.textContent='This comparison link has expired';}else setTimeout(expire,Math.min(2147483647,remaining));}expire();
}
start().catch(error=>{state.classList.remove('loading');state.textContent=error.name==='AbortError'?'':error.message;});
+1 -1
مشاهده پرونده
@@ -1 +1 @@
<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Schematic API reference</title><link rel="stylesheet" href="/assets/upload.css"></head><body><main><p class="eyebrow">API V1</p><h1>Schematic rendering API</h1><p>Authenticate uploads with <code>X-API-Key</code>. Native binary OLE and ASCII SchDoc format 5.0 are accepted. Files are parsed asynchronously; HTTP 202 confirms admission, not rendering fidelity.</p><h2>Upload</h2><p><code>POST /api/v1/schematics/render</code></p><p>Multipart fields: <code>file</code> (required), <code>access</code> (private or unlisted; defaults to private), <code>expires_in</code> (optional seconds, bounded by server policy).</p><h2>Poll</h2><p><code>GET /api/v1/schematics/{id}/status</code></p><p>States: queued, processing, completed, failed. Private reads require <code>X-Viewer-Token</code> from the upload response, or the service API key. Completed responses include compatibility and diagnostics.</p><h2>Retrieve</h2><p><code>GET /api/v1/schematics/{id}/data</code> returns the versioned structured document. <code>GET /api/v1/schematics/{id}/svg</code> returns the generated vector drawing.</p><h2>View</h2><p>Open the exact <code>viewer_url</code> from the upload response. The private token is in its fragment. It is stored for this browser tab and sent only as a request header. The viewer has no panels or toolbar.</p><h2>Errors</h2><p>JSON errors include <code>success: false</code> and <code>error.code</code> / <code>error.message</code>. Common HTTP codes: 401 invalid service key; 404 missing or inaccessible schematic; 409 still processing; 410 expired; 413 too large; 415 wrong extension; 422 invalid schematic; 503 storage unavailable or queue full.</p><p><a href="/openapi.json">Download OpenAPI schema</a> · <a href="/">Upload a schematic</a></p></main></body></html> <!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Schematic API reference</title><link rel="stylesheet" href="/assets/upload.css"></head><body><main><p class="eyebrow">API V1</p><h1>Schematic rendering API</h1><p>Authenticate uploads with <code>X-API-Key</code>. Native binary OLE and ASCII SchDoc format 5.0 are accepted. Files are parsed asynchronously; HTTP 202 confirms admission, not rendering fidelity.</p><h2>Upload</h2><p><code>POST /api/v1/schematics/render</code></p><p>Multipart fields: <code>file</code> (required), <code>access</code> (private or unlisted; defaults to private), <code>expires_in</code> (optional seconds, bounded by server policy).</p><h2>Poll</h2><p><code>GET /api/v1/schematics/{id}/status</code></p><p>States: queued, processing, completed, failed. Private reads require <code>X-Viewer-Token</code> from the upload response, or the service API key. Completed responses include compatibility and diagnostics.</p><h2>Retrieve</h2><p><code>GET /api/v1/schematics/{id}/data</code> returns the versioned structured document. <code>GET /api/v1/schematics/{id}/svg</code> returns the generated vector drawing.</p><h2>View</h2><p>Open the exact <code>viewer_url</code> from the upload response. The private token is in its fragment. It is stored for this browser tab and sent only as a request header. The viewer has no panels or toolbar.</p><h2>Compare two schematics</h2><p><code>POST /api/v1/comparisons</code> (alias <code>/api/compare</code>) accepts multipart <code>old_file</code> and <code>new_file</code> with the same API key. It returns HTTP 202 and a private comparison URL. Poll <code>GET /api/v1/comparisons/{id}</code> using the returned read token for the completed summary and structural changes. <a href="/compare">Open comparison upload</a>.</p><h2>Errors</h2><p>JSON errors include <code>success: false</code> and <code>error.code</code> / <code>error.message</code>. Common HTTP codes: 401 invalid service key; 404 missing or inaccessible schematic; 409 still processing; 410 expired; 413 too large; 415 wrong extension; 422 invalid schematic; 503 storage unavailable or queue full.</p><p><a href="/openapi.json">Download OpenAPI schema</a> · <a href="/">Upload a schematic</a></p></main></body></html>
+1 -1
مشاهده پرونده
@@ -1,2 +1,2 @@
<!doctype html> <!doctype html>
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Altium schematic upload</title><link rel="stylesheet" href="/assets/upload.css"><script type="module" src="/assets/upload.js"></script></head><body><main><p class="eyebrow">NATIVE ALTIUM · SCHDOC</p><h1>Open your schematic.</h1><p>Upload a native schematic to create a private, expiring viewer link.</p><form id="upload"><label for="key">API key</label><input id="key" type="password" autocomplete="off" placeholder="Configured service key"><label for="file">Schematic file</label><input id="file" type="file" accept=".SchDoc" required><button type="submit">Render schematic <span aria-hidden="true">↗</span></button></form><output id="result" role="status"></output><p class="note">Compatibility diagnostics are returned by the API. Review them before relying on visual fidelity.</p><a href="/docs">API reference</a></main></body></html> <html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Altium schematic upload</title><link rel="stylesheet" href="/assets/upload.css"><script type="module" src="/assets/upload.js"></script></head><body><main><p class="eyebrow">NATIVE ALTIUM · SCHDOC</p><h1>Open your schematic.</h1><p>Upload a native schematic to create a private, expiring viewer link.</p><form id="upload"><label for="key">API key</label><input id="key" type="password" autocomplete="off" placeholder="Configured service key"><label for="file">Schematic file</label><input id="file" type="file" accept=".SchDoc" required><button type="submit">Render schematic <span aria-hidden="true">↗</span></button></form><output id="result" role="status"></output><p class="note">Compatibility diagnostics are returned by the API. Review them before relying on visual fidelity.</p><a href="/compare">Compare two schematics</a> · <a href="/docs">API reference</a></main></body></html>
+56
مشاهده پرونده
@@ -0,0 +1,56 @@
import { Camera } from './camera.js';
/** Pointer/touch camera adapter shared by both comparison panes. */
export function bindCamera(surface, svg, bounds, onChange = () => {}) {
const camera = new Camera(bounds, surface.clientWidth, surface.clientHeight);
let frame = 0;
const pointers = new Map();
function paint(notify = true) {
if (!frame) frame = requestAnimationFrame(() => { frame = 0; svg.setAttribute('viewBox', camera.viewBox); });
if (notify) onChange(view);
}
const view = { camera, paint,
pose() { return { center: camera.world(camera.width/2,camera.height/2), scale: camera.scale }; },
apply({center,scale}) { camera.scale=scale; camera.x=camera.width/2-center.x*scale; camera.y=camera.height/2-center.y*scale; paint(false); },
fit() { camera.fit(); paint(); },
focus(box) {
const scale=Math.min(camera.width/Math.max(20,box.width+30),camera.height/Math.max(20,box.height+30),camera.fitScale*100);
view.apply({center:{x:box.x+box.width/2,y:box.y+box.height/2},scale}); onChange(view);
},
destroy() { cancelAnimationFrame(frame); observer.disconnect(); events.abort(); }
};
const events = new AbortController(); const options={ signal:events.signal };
surface.addEventListener('wheel',e => {
e.preventDefault();const rect=surface.getBoundingClientRect();
const delta=e.deltaY*(e.deltaMode===1?16:e.deltaMode===2?camera.height:1);
camera.zoom(Math.exp(-Math.max(-400,Math.min(400,delta))*.002),e.clientX-rect.left,e.clientY-rect.top);paint();
},{...options,passive:false});
surface.addEventListener('pointerdown',e => {
if(e.button!==0)return;surface.focus();surface.setPointerCapture(e.pointerId);
pointers.set(e.pointerId,{x:e.clientX,y:e.clientY});
},options);
function metrics() {
const p=[...pointers.values()], rect=surface.getBoundingClientRect();
return { x:p.reduce((s,p)=>s+p.x,0)/p.length-rect.left,y:p.reduce((s,p)=>s+p.y,0)/p.length-rect.top,
distance:p.length>1?Math.hypot(p[0].x-p[1].x,p[0].y-p[1].y):0 };
}
surface.addEventListener('pointermove',e => {
if(!pointers.has(e.pointerId))return;
const before=metrics();pointers.set(e.pointerId,{x:e.clientX,y:e.clientY});const after=metrics();
if(before.distance&&after.distance)camera.zoom(after.distance/before.distance,before.x,before.y);
camera.pan(after.x-before.x,after.y-before.y);paint();
},options);
for(const type of ['pointerup','pointercancel','lostpointercapture'])surface.addEventListener(type,e=>pointers.delete(e.pointerId),options);
surface.addEventListener('dblclick',()=>view.fit(),options);
surface.addEventListener('keydown',e=>{
if(e.key==='Home'||e.key==='0')camera.fit();
else if(e.key==='+'||e.key==='=')camera.zoom(1.25,camera.width/2,camera.height/2);
else if(e.key==='-')camera.zoom(.8,camera.width/2,camera.height/2);
else if(e.key.startsWith('Arrow'))camera.pan(e.key==='ArrowLeft'?40:e.key==='ArrowRight'?-40:0,e.key==='ArrowUp'?40:e.key==='ArrowDown'?-40:0);
else return;e.preventDefault();paint();
},options);
const observer=new ResizeObserver(()=>{
const pose=view.pose();camera.resize(surface.clientWidth,surface.clientHeight);
camera.fitScale=Math.min(camera.width/bounds.width,camera.height/bounds.height);view.apply(pose);
});observer.observe(surface);paint(false);return view;
}
@@ -0,0 +1,46 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { bindCamera } from './interactions.js';
const close=(a,b)=>assert.ok(Math.abs(a-b)<1e-8,`${a} != ${b}`);
function setup(t) {
const saved={raf:globalThis.requestAnimationFrame,caf:globalThis.cancelAnimationFrame,observer:globalThis.ResizeObserver};
const frames=[];
globalThis.requestAnimationFrame=fn=>{frames.push(fn);return frames.length;};globalThis.cancelAnimationFrame=()=>{};
globalThis.ResizeObserver=class {observe(){}disconnect(){}};
t.after(()=>{globalThis.requestAnimationFrame=saved.raf;globalThis.cancelAnimationFrame=saved.caf;globalThis.ResizeObserver=saved.observer;});
function surface(left=100) {
const surface=new EventTarget();Object.assign(surface,{clientWidth:800,clientHeight:600,
getBoundingClientRect:()=>({left,top:20}),focus(){},setPointerCapture(){}});
const attributes={};return {surface,svg:{setAttribute:(k,v)=>attributes[k]=v},attributes};
}
return {surface,flush:()=>{for(const fn of frames.splice(0))fn();}};
}
function send(surface,type,values){const event=new Event(type,{cancelable:true});Object.assign(event,values);surface.dispatchEvent(event);return event;}
test('wheel zoom uses the pane offset and synchronizes world cameras',t=>{
const env=setup(t),a=env.surface(),b=env.surface(1000),bounds={x:0,y:-760,width:1150,height:760};
let second;const first=bindCamera(a.surface,a.svg,bounds,source=>second.apply(source.pose()));second=bindCamera(b.surface,b.svg,bounds);
const anchor=first.camera.world(250,200);
const event=send(a.surface,'wheel',{deltaY:-120,deltaMode:0,clientX:350,clientY:220});
assert.ok(event.defaultPrevented);
close(first.camera.world(250,200).x,anchor.x);close(first.camera.world(250,200).y,anchor.y);
close(first.pose().center.x,second.pose().center.x);close(first.camera.scale,second.camera.scale);
env.flush();assert.equal(a.attributes.viewBox,b.attributes.viewBox);first.destroy();second.destroy();
});
test('two-pointer pinch preserves the moving center without rasterizing',t=>{
const env=setup(t),{surface,svg}=env.surface();const view=bindCamera(surface,svg,{x:0,y:0,width:100,height:100});
send(surface,'pointerdown',{pointerId:1,button:0,clientX:200,clientY:200});
send(surface,'pointerdown',{pointerId:2,button:0,clientX:300,clientY:200});
const anchor=view.camera.world(150,180),scale=view.camera.scale;
send(surface,'pointermove',{pointerId:2,clientX:400,clientY:200});
close(view.camera.scale,scale*2);close(view.camera.world(200,180).x,anchor.x);close(view.camera.world(200,180).y,anchor.y);
view.destroy();
});
test('focus fits change bounds and detaching stops interaction',t=>{
const env=setup(t),{surface,svg}=env.surface();const view=bindCamera(surface,svg,{x:0,y:0,width:1000,height:1000});
view.focus({x:100,y:200,width:20,height:40});const center=view.pose().center;
close(center.x,110);close(center.y,220);view.destroy();const scale=view.camera.scale;
send(surface,'wheel',{deltaY:-120,deltaMode:0,clientX:200,clientY:200});close(view.camera.scale,scale);
});
+28
مشاهده پرونده
@@ -0,0 +1,28 @@
#!/usr/bin/env python3
"""Actual HTTP upload → durable comparison worker → private result/SVG check."""
import argparse
import json
import os
from pathlib import Path
import time
import httpx
p=argparse.ArgumentParser();p.add_argument('old_file',type=Path);p.add_argument('new_file',type=Path);p.add_argument('--url',default='http://127.0.0.1:8000');args=p.parse_args()
with httpx.Client(base_url=args.url,timeout=60) as client:
response=client.post('/api/compare',headers={'X-API-Key':os.environ.get('API_KEY','')},
files={'old_file':(args.old_file.name,args.old_file.read_bytes()),'new_file':(args.new_file.name,args.new_file.read_bytes())})
assert response.status_code==202,response.text
created=response.json();identifier=created['comparison_id'];headers={'X-Viewer-Token':created['viewer_token']};base='/api/v1/comparisons/'+identifier
assert client.get(base).status_code==404
deadline=time.monotonic()+120
while time.monotonic()<deadline:
result=client.get(base,headers=headers).json()
if result['status'] in ('completed','failed'):break
time.sleep(.5)
assert result['status']=='completed',result
for side in ('old','new'):
svg=client.get(base+'/svg/'+side,headers=headers)
assert svg.status_code==200 and '<svg' in svg.text
assert client.get(base+'/svg/'+side).status_code==404
assert client.get('/compare/'+identifier).status_code==200
print(json.dumps({'id':identifier,'status':result['status'],'summary':result['summary'],
'total_changed':result['total_changed'],'private_access_enforced':True},indent=2))