@@ -4,6 +4,7 @@ import time
|
||||
from sqlalchemy import select, delete, or_
|
||||
from .db import Job, sessions, engine
|
||||
from .storage import storage
|
||||
from .job_types import comparison_sources
|
||||
|
||||
log=logging.getLogger(__name__)
|
||||
|
||||
@@ -15,8 +16,7 @@ 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 source in comparison_sources(job.input_key): storage().delete(source)
|
||||
for attempt in (1,2):
|
||||
for name in ('data.json','drawing.svg','old.svg','new.svg'):
|
||||
storage().delete(f'{job.id}/attempt-{attempt}/{name}')
|
||||
|
||||
@@ -2,12 +2,13 @@
|
||||
from typing import Protocol
|
||||
from .models.schematic import Document
|
||||
from .parser.schdoc_parser import parse_schdoc
|
||||
from .parser.pcb_parser import parse_pcbdoc
|
||||
from .parser.record_decoder import ParseError
|
||||
|
||||
class DocumentParser(Protocol):
|
||||
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, 'pcb': parse_pcbdoc}
|
||||
|
||||
def parse_document(data: bytes, filename: str, identifier: str, document_type='schematic', **options) -> Document:
|
||||
parser=PARSERS.get(document_type)
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
"""Server-owned immutable input names classify jobs, including legacy uploads."""
|
||||
INPUT_KINDS = {
|
||||
'input.SchDoc': 'schematic_render',
|
||||
'comparison.json': 'schematic_comparison',
|
||||
'input.PcbDoc': 'pcb_render',
|
||||
'pcb-comparison.json': 'pcb_comparison',
|
||||
}
|
||||
|
||||
|
||||
def job_kind(input_key):
|
||||
return INPUT_KINDS.get(input_key.rsplit('/', 1)[-1])
|
||||
|
||||
|
||||
def comparison_sources(input_key):
|
||||
kind = job_kind(input_key)
|
||||
if kind not in ('schematic_comparison', 'pcb_comparison'):
|
||||
return ()
|
||||
prefix = input_key.rsplit('/', 1)[0]
|
||||
suffix = 'PcbDoc' if kind == 'pcb_comparison' else 'SchDoc'
|
||||
return tuple(f'{prefix}/{side}.{suffix}' for side in ('old', 'new'))
|
||||
@@ -22,6 +22,7 @@ from .fonts import fonts, stylesheet
|
||||
from .models.api import UploadResponse, RenderStatus, ErrorEnvelope
|
||||
from .models.schematic import Document
|
||||
from .uploads import read_schematic
|
||||
from .job_types import job_kind
|
||||
|
||||
FRONTEND = Path(__file__).resolve().parents[2] / 'frontend'
|
||||
ID = re.compile(r'^[a-f0-9]{48}$')
|
||||
@@ -54,7 +55,7 @@ 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)
|
||||
file_count = 2 if scope['path'] in ('/api/compare','/api/v1/comparisons') else 1
|
||||
file_count = 2 if scope['path'] in ('/api/compare','/api/v1/comparisons','/api/v1/pcbs/comparisons') else 1
|
||||
limit = file_count * settings().max_upload_bytes + 1024 * 1024
|
||||
received = 0
|
||||
async def bounded_receive():
|
||||
@@ -71,7 +72,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 in ('/api/v1/schematics/render','/api/compare','/api/v1/comparisons'):
|
||||
if request.method == 'POST' and request.url.path in ('/api/v1/schematics/render','/api/compare','/api/v1/comparisons','/api/v1/pcbs/render','/api/v1/pcbs/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)
|
||||
@@ -109,10 +110,11 @@ def health():
|
||||
with sessions()() as db: db.execute(text('SELECT 1'))
|
||||
return {'status': 'ok'}
|
||||
|
||||
def authorize(identifier, key, token, *, comparison=False):
|
||||
def authorize(identifier, key, token, *, comparison=False, resource_kind=None):
|
||||
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 or job.input_key.endswith('/comparison.json') != comparison:
|
||||
expected_kind = resource_kind or ('schematic_comparison' if comparison else 'schematic_render')
|
||||
if job is None or job_kind(job.input_key) != expected_kind:
|
||||
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)))):
|
||||
@@ -201,7 +203,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','compare.js','compare.css','compare-upload.js','interactions.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','pcb.js','pcb.css','pcb-layers.js'}: error('not_found', 'Asset not found', 404)
|
||||
return FileResponse(FRONTEND / name)
|
||||
|
||||
@app.get('/docs', include_in_schema=False)
|
||||
@@ -224,3 +226,5 @@ async def database_error(request, exc):
|
||||
# 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)
|
||||
from .pcb_api import register_pcb_routes
|
||||
register_pcb_routes(app, authorize=authorize, require_api=require_api, error=error)
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
"""PCB contracts extend geometry without changing the schematic wire schema."""
|
||||
from typing import Literal
|
||||
from pydantic import BaseModel, Field
|
||||
from .schematic import Document, Primitive, SchematicObject, Point, Bounds
|
||||
from .api import RenderStatus
|
||||
|
||||
|
||||
class PCBPrimitive(Primitive):
|
||||
layer_ids: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class PCBObject(SchematicObject):
|
||||
native_uid: str = ''
|
||||
layer_ids: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class Footprint(BaseModel):
|
||||
id: str
|
||||
designator: str = ''
|
||||
name: str = ''
|
||||
value: str = ''
|
||||
comment: str = ''
|
||||
description: str = ''
|
||||
library_reference: str = ''
|
||||
source_library: str = ''
|
||||
position: Point
|
||||
rotation: float = 0
|
||||
mirrored: bool = False
|
||||
bounds: Bounds
|
||||
parameters: dict[str, str] = Field(default_factory=dict)
|
||||
primitive_ids: list[str] = Field(default_factory=list)
|
||||
object_ids: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class PCBData(Document):
|
||||
document_type: Literal['pcb'] = 'pcb'
|
||||
components: list[Footprint]
|
||||
primitives: list[PCBPrimitive]
|
||||
objects: list[PCBObject]
|
||||
|
||||
|
||||
class PCBStatus(RenderStatus):
|
||||
document_type: Literal['pcb', 'pcb_comparison']
|
||||
status_url: str
|
||||
layers: list[dict] | None = None
|
||||
net_count: int | None = None
|
||||
|
||||
|
||||
class PCBUploadResponse(PCBStatus):
|
||||
viewer_token: str | None = None
|
||||
|
||||
|
||||
class PCBComparisonStatus(PCBStatus):
|
||||
comparison_id: str
|
||||
url: str
|
||||
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 PCBComparisonUploadResponse(PCBComparisonStatus):
|
||||
viewer_token: str | None = None
|
||||
@@ -0,0 +1,106 @@
|
||||
"""Checked Altium PCB framing. No embedded content is decompressed or executed."""
|
||||
|
||||
import math
|
||||
import struct
|
||||
from .record_decoder import ParseError, properties
|
||||
|
||||
MAX_ITEMS = 100_000
|
||||
MAX_BLOCK = 16 * 1024 * 1024
|
||||
|
||||
|
||||
class Reader:
|
||||
def __init__(self, data, label="PCB"):
|
||||
self.data = memoryview(data)
|
||||
self.offset = 0
|
||||
self.label = label
|
||||
|
||||
@property
|
||||
def remaining(self):
|
||||
return len(self.data) - self.offset
|
||||
|
||||
def take(self, length):
|
||||
if length < 0 or length > self.remaining:
|
||||
raise ParseError(
|
||||
f"{self.label}: truncated data at byte {self.offset}, need {length}"
|
||||
)
|
||||
start = self.offset
|
||||
self.offset += length
|
||||
return self.data[start : self.offset]
|
||||
|
||||
def read(self, fmt):
|
||||
result = struct.unpack("<" + fmt, self.take(struct.calcsize("<" + fmt)))
|
||||
if any(isinstance(v, float) and not math.isfinite(v) for v in result):
|
||||
raise ParseError(f"{self.label}: non-finite binary number")
|
||||
return result[0] if len(result) == 1 else result
|
||||
|
||||
def block(self):
|
||||
length = self.read("I")
|
||||
if length > MAX_BLOCK:
|
||||
raise ParseError(f"{self.label}: subrecord exceeds {MAX_BLOCK} bytes")
|
||||
return self.take(length)
|
||||
|
||||
def pascal(self):
|
||||
return bytes(self.take(self.read("B"))).decode("cp1252", errors="replace")
|
||||
|
||||
|
||||
def text_records(data, name):
|
||||
reader = Reader(data, name)
|
||||
result = []
|
||||
while reader.remaining:
|
||||
if len(result) >= MAX_ITEMS:
|
||||
raise ParseError(f"{name}: excessive record count")
|
||||
result.append(properties(bytes(reader.block())))
|
||||
return result
|
||||
|
||||
|
||||
def binary_records(data, name, tag, blocks=1):
|
||||
reader = Reader(data, name)
|
||||
index = 0
|
||||
while reader.remaining:
|
||||
if index >= MAX_ITEMS:
|
||||
raise ParseError(f"{name}: excessive record count")
|
||||
actual = reader.read("B")
|
||||
if actual != tag:
|
||||
raise ParseError(
|
||||
f"{name}: unsupported tag {actual} at record {index}, expected {tag}"
|
||||
)
|
||||
yield index, [bytes(reader.block()) for _ in range(blocks)]
|
||||
index += 1
|
||||
|
||||
|
||||
def wide_strings(data):
|
||||
reader = Reader(data, "WideStrings6")
|
||||
result = {}
|
||||
while reader.remaining:
|
||||
if len(result) >= MAX_ITEMS:
|
||||
raise ParseError("Excessive wide string count")
|
||||
index = reader.read("I")
|
||||
length = reader.read("I")
|
||||
# Altium writes empty strings with length=2 but no payload in some exports.
|
||||
if (
|
||||
length == 2
|
||||
and bytes(reader.data[reader.offset : reader.offset + 2]) != b"\0\0"
|
||||
):
|
||||
payload = b""
|
||||
else:
|
||||
if length > MAX_BLOCK:
|
||||
raise ParseError("Wide string exceeds size limit")
|
||||
payload = reader.take(length)
|
||||
if len(payload) % 2:
|
||||
raise ParseError("Odd UTF-16 string length")
|
||||
result[index] = (
|
||||
bytes(payload).decode("utf-16le", errors="replace").rstrip("\x00")
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
def at(data, offset, fmt, default=None):
|
||||
size = struct.calcsize("<" + fmt)
|
||||
if offset < 0 or offset + size > len(data):
|
||||
if default is not None:
|
||||
return default
|
||||
raise ParseError(f"Truncated PCB field at offset {offset}, need {size} bytes")
|
||||
values = struct.unpack_from("<" + fmt, data, offset)
|
||||
if any(isinstance(v, float) and not math.isfinite(v) for v in values):
|
||||
raise ParseError("Non-finite PCB field")
|
||||
return values[0] if len(values) == 1 else values
|
||||
@@ -0,0 +1,44 @@
|
||||
"""Cheap bounded PCB envelope validation; complete decoding stays in the worker."""
|
||||
import io
|
||||
import struct
|
||||
import olefile
|
||||
from .record_decoder import ParseError
|
||||
from .pcb_binary import Reader, text_records
|
||||
|
||||
SIGNATURE = b'\xd0\xcf\x11\xe0\xa1\xb1\x1a\xe1'
|
||||
|
||||
|
||||
def identify_pcb(data):
|
||||
if not data.startswith(SIGNATURE):
|
||||
raise ParseError('PcbDoc must be an OLE compound document')
|
||||
try:
|
||||
with olefile.OleFileIO(io.BytesIO(data), raise_defects=olefile.DEFECT_INCORRECT) as ole:
|
||||
if len(ole.listdir()) > 1024:
|
||||
raise ParseError('Excessive PCB stream count')
|
||||
for name, limit in [('FileHeaderSix', 4096), ('Board6/Data', 4 * 1024**2)]:
|
||||
if not ole.exists(name) or ole.get_type(name) != 2:
|
||||
raise ParseError(f'Missing PCB stream {name}')
|
||||
if ole.get_size(name) > limit:
|
||||
raise ParseError(f'PCB stream {name} exceeds size limit')
|
||||
reader = Reader(ole.openstream('FileHeaderSix').read(), 'FileHeaderSix')
|
||||
length = reader.read('I'); envelope = reader.pascal()
|
||||
if length != len(envelope) or envelope != 'PCB 6.0 Binary File':
|
||||
raise ParseError('Unsupported PCB file envelope')
|
||||
records = text_records(ole.openstream('Board6/Data').read(), 'Board6')
|
||||
if len(records) != 1 or records[0].get('KIND') != 'Protel_Advanced_PCB':
|
||||
raise ParseError('Invalid Board6 identification')
|
||||
if records[0].get('VERSION') not in ('5.0', '5.01', '6.0'):
|
||||
raise ParseError('Unsupported PCB board version')
|
||||
except (OSError, IOError, ValueError, IndexError, struct.error) as exc:
|
||||
if isinstance(exc, ParseError): raise
|
||||
raise ParseError('Invalid PCB compound document') from exc
|
||||
|
||||
|
||||
def contains_pcb(data):
|
||||
"""Reject PCB data renamed to SchDoc without altering legacy corrupt-file handling."""
|
||||
if not data.startswith(SIGNATURE): return False
|
||||
try:
|
||||
with olefile.OleFileIO(io.BytesIO(data)) as ole:
|
||||
return ole.exists('Board6/Data')
|
||||
except (OSError, ValueError, IndexError, struct.error):
|
||||
return False
|
||||
@@ -0,0 +1,155 @@
|
||||
import math
|
||||
import re
|
||||
from app.models.schematic import Point, Bounds
|
||||
from .geometry import bounds, poly_path, transform_point
|
||||
from .record_decoder import ParseError
|
||||
|
||||
# PCB integer coordinate unit = 1/10000 mil. Common viewer unit = 10 mil.
|
||||
BINARY_SCALE = 100000
|
||||
|
||||
|
||||
def raw_unit(value):
|
||||
n = value / BINARY_SCALE
|
||||
if not math.isfinite(n) or abs(n) > 100000:
|
||||
raise ParseError("Invalid or out-of-range PCB coordinate")
|
||||
return n
|
||||
|
||||
|
||||
def unit(value):
|
||||
match = re.fullmatch(r"\s*([-+\d.eE]+)\s*(mil|mm|in|inch)?\s*", str(value), re.I)
|
||||
if not match:
|
||||
raise ParseError(f"Invalid PCB unit: {str(value)[:80]}")
|
||||
n = float(match[1])
|
||||
suffix = (match[2] or "mil").lower()
|
||||
factor = {"mil": 0.1, "mm": 1 / 0.254, "in": 100, "inch": 100}[suffix]
|
||||
result = n * factor
|
||||
if not math.isfinite(result) or abs(result) > 100000:
|
||||
raise ParseError("PCB unit out of bounds")
|
||||
return result
|
||||
|
||||
|
||||
def xy(x, y):
|
||||
return Point(x=raw_unit(x), y=-raw_unit(y))
|
||||
|
||||
|
||||
def ellipse_path(cx, cy, rx, ry=None):
|
||||
ry = rx if ry is None else ry
|
||||
return f"M{cx - rx:.15g},{cy:.15g} A{rx:.15g},{ry:.15g} 0 1 0 {cx + rx:.15g},{cy:.15g} A{rx:.15g},{ry:.15g} 0 1 0 {cx - rx:.15g},{cy:.15g} Z"
|
||||
|
||||
|
||||
def arc_path(center, radius, start, end):
|
||||
if not all(math.isfinite(v) for v in (radius,start,end)): raise ParseError("Non-finite arc geometry")
|
||||
if radius <= 0:
|
||||
raise ParseError("Arc radius must be positive")
|
||||
if abs(end - start) >= 359.99999 or start == end:
|
||||
return ellipse_path(center.x, center.y, radius)
|
||||
a, b = math.radians(start), math.radians(end)
|
||||
x1, y1 = center.x + radius * math.cos(a), center.y - radius * math.sin(a)
|
||||
x2, y2 = center.x + radius * math.cos(b), center.y - radius * math.sin(b)
|
||||
return f"M{x1:.15g},{y1:.15g} A{radius:.15g},{radius:.15g} 0 {int((end - start) % 360 > 180)} 0 {x2:.15g},{y2:.15g}"
|
||||
|
||||
|
||||
def pad_shape(position, w, h, shape, rotation, radius_percent=0):
|
||||
if w <= 0 or h <= 0:
|
||||
raise ParseError("Pad dimensions must be positive")
|
||||
# Build in local coordinates, then place absolute points. Exact arcs use a path transform.
|
||||
angle = math.radians(-rotation)
|
||||
|
||||
def q(x, y):
|
||||
return Point(
|
||||
x=position.x + x * math.cos(angle) - y * math.sin(angle),
|
||||
y=position.y + x * math.sin(angle) + y * math.cos(angle),
|
||||
)
|
||||
|
||||
def pair(p):
|
||||
return f"{p.x:.15g},{p.y:.15g}"
|
||||
|
||||
if (
|
||||
shape == 1
|
||||
): # Circle or oval/obround; unequal sizes form a capsule, not an ellipse.
|
||||
r = min(w, h) / 2
|
||||
elif shape == 9:
|
||||
r = min(w, h) * radius_percent / 200
|
||||
else:
|
||||
r = 0
|
||||
if shape == 3:
|
||||
# Altium octagon chamfers at 45 degrees.
|
||||
c = min(w, h) / (2 + math.sqrt(2))
|
||||
pts = [
|
||||
q(x, y)
|
||||
for x, y in [
|
||||
(-w / 2 + c, -h / 2),
|
||||
(w / 2 - c, -h / 2),
|
||||
(w / 2, -h / 2 + c),
|
||||
(w / 2, h / 2 - c),
|
||||
(w / 2 - c, h / 2),
|
||||
(-w / 2 + c, h / 2),
|
||||
(-w / 2, h / 2 - c),
|
||||
(-w / 2, -h / 2 + c),
|
||||
]
|
||||
]
|
||||
return poly_path(pts, True), bounds(pts)
|
||||
if shape not in (1, 2, 9):
|
||||
raise ParseError(f"Unsupported pad shape {shape}")
|
||||
corners = [
|
||||
q(x, y)
|
||||
for x, y in [(-w / 2, -h / 2), (w / 2, -h / 2), (w / 2, h / 2), (-w / 2, h / 2)]
|
||||
]
|
||||
if r <= 0:
|
||||
return poly_path(corners, True), bounds(corners)
|
||||
pts = [
|
||||
q(x, y)
|
||||
for x, y in [
|
||||
(-w / 2 + r, -h / 2),
|
||||
(w / 2 - r, -h / 2),
|
||||
(w / 2, -h / 2 + r),
|
||||
(w / 2, h / 2 - r),
|
||||
(w / 2 - r, h / 2),
|
||||
(-w / 2 + r, h / 2),
|
||||
(-w / 2, h / 2 - r),
|
||||
(-w / 2, -h / 2 + r),
|
||||
]
|
||||
]
|
||||
d = f"M{pair(pts[0])} L{pair(pts[1])} A{r:.15g},{r:.15g} {-rotation:.15g} 0 1 {pair(pts[2])} L{pair(pts[3])} A{r:.15g},{r:.15g} 0 0 1 {pair(pts[4])} L{pair(pts[5])} A{r:.15g},{r:.15g} 0 0 1 {pair(pts[6])} L{pair(pts[7])} A{r:.15g},{r:.15g} 0 0 1 {pair(pts[0])} Z"
|
||||
return d, bounds(corners)
|
||||
|
||||
|
||||
def property_outline(p):
|
||||
vertices = []
|
||||
for i in range(20000):
|
||||
if f"VX{i}" not in p:
|
||||
break
|
||||
if f"VY{i}" not in p: raise ParseError("PCB outline vertex is missing Y")
|
||||
if int(p.get(f"KIND{i}",0)) and not all(f"{key}{i}" in p for key in ("CX","CY","R","SA","EA")):
|
||||
raise ParseError("PCB outline arc is missing native geometry")
|
||||
vertices.append(
|
||||
(
|
||||
Point(x=unit(p[f"VX{i}"]), y=-unit(p.get(f"VY{i}", "0mil"))),
|
||||
int(p.get(f"KIND{i}", 0)),
|
||||
Point(
|
||||
x=unit(p.get(f"CX{i}", "0mil")), y=-unit(p.get(f"CY{i}", "0mil"))
|
||||
),
|
||||
unit(p.get(f"R{i}", "0mil")),
|
||||
float(p.get(f"SA{i}", 0)),
|
||||
float(p.get(f"EA{i}", 0)),
|
||||
)
|
||||
)
|
||||
if not vertices:
|
||||
return "", bounds([])
|
||||
pts = [v[0] for v in vertices]
|
||||
commands = [f"M{pts[0].x:.15g},{pts[0].y:.15g}"]
|
||||
for i, (pos, kind, center, r, sa, ea) in enumerate(vertices):
|
||||
if kind and r > 0:
|
||||
# Arc belongs to its starting vertex, ending at the next vertex.
|
||||
arc = arc_path(center, r, sa, ea)
|
||||
commands.append("L" + arc.split(" ", 1)[0][1:])
|
||||
commands.append(arc.split(" ", 1)[1])
|
||||
pts.extend(
|
||||
[
|
||||
Point(x=center.x - r, y=center.y - r),
|
||||
Point(x=center.x + r, y=center.y + r),
|
||||
]
|
||||
)
|
||||
nxt = vertices[(i + 1) % len(vertices)][0]
|
||||
commands.append(f"L{nxt.x:.15g},{nxt.y:.15g}")
|
||||
return " ".join(commands) + " Z", bounds(pts)
|
||||
@@ -0,0 +1,947 @@
|
||||
"""Independent, bounded reader for the documented PcbDoc 6.0 binary record family."""
|
||||
|
||||
import hashlib
|
||||
import io
|
||||
import math
|
||||
import re
|
||||
import struct
|
||||
from collections import Counter
|
||||
import olefile
|
||||
from app.models.schematic import Sheet, Point, Bounds, Diagnostic, BoardLayer, Net
|
||||
from app.models.pcb import PCBData as Document, Footprint as Component, PCBObject as SchematicObject, PCBPrimitive as Primitive
|
||||
from .record_decoder import ParseError, properties
|
||||
from .pcb_binary import Reader, at, text_records, binary_records, wide_strings
|
||||
from .pcb_geometry import (
|
||||
raw_unit,
|
||||
unit,
|
||||
xy,
|
||||
ellipse_path,
|
||||
arc_path,
|
||||
pad_shape,
|
||||
property_outline,
|
||||
)
|
||||
from .geometry import bounds, union, poly_path, transform_point
|
||||
|
||||
MAX_TOTAL = 64 * 1024 * 1024
|
||||
SPECIAL = {
|
||||
32: ("Bottom Layer", "#5484ed", "copper"),
|
||||
33: ("Top Overlay", "#f6e4b1", "silkscreen"),
|
||||
34: ("Bottom Overlay", "#c29ef1", "silkscreen"),
|
||||
35: ("Top Paste", "#92b8a0", "paste"),
|
||||
36: ("Bottom Paste", "#81a0b5", "paste"),
|
||||
37: ("Top Solder", "#966ac1", "mask"),
|
||||
38: ("Bottom Solder", "#b78b99", "mask"),
|
||||
55: ("Drill Guide", "#d5d8dc", "mechanical"),
|
||||
56: ("Keep-Out Layer", "#d374dc", "mechanical"),
|
||||
73: ("Drill Drawing", "#cccbd0", "mechanical"),
|
||||
74: ("Multi-Layer", "#d9b660", "copper"),
|
||||
81: ("Drill holes", "#08151b", "drill"),
|
||||
0: ("Board Outline", "#67bc9d", "outline"),
|
||||
}
|
||||
|
||||
|
||||
def layer_number(name):
|
||||
name = name.upper().replace(" ", "").replace("_", "")
|
||||
fixed = {
|
||||
"TOP": 1,
|
||||
"TOPLAYER": 1,
|
||||
"BOTTOM": 32,
|
||||
"BOTTOMLAYER": 32,
|
||||
"TOPOVERLAY": 33,
|
||||
"BOTTOMOVERLAY": 34,
|
||||
"TOPPASTE": 35,
|
||||
"BOTTOMPASTE": 36,
|
||||
"TOPSOLDER": 37,
|
||||
"BOTTOMSOLDER": 38,
|
||||
"KEEPOUT": 56,
|
||||
"KEEPOUTLAYER": 56,
|
||||
"MULTILAYER": 74,
|
||||
"DRILLGUIDE": 55,
|
||||
"DRILLDRAWING": 73,
|
||||
}
|
||||
if name in fixed:
|
||||
return fixed[name]
|
||||
m = re.fullmatch(r"(MID(?:LAYER)?|PLANE|MECHANICAL)(\d+)", name)
|
||||
if m:
|
||||
n = int(m[2])
|
||||
if m[1].startswith("MID"):
|
||||
return n + 1
|
||||
if m[1] == "PLANE":
|
||||
return n + 38
|
||||
return n + 56 if n <= 16 else 0x01020000 + n
|
||||
return 0
|
||||
|
||||
|
||||
def resolve_layer(legacy, extended=0):
|
||||
# The standard copper/overlay IDs remain authoritative in V6 records.
|
||||
if 0x01020011 <= extended <= 0x0102FFFF:
|
||||
return extended
|
||||
return legacy
|
||||
|
||||
|
||||
def parse_pcbdoc(data: bytes, filename: str, document_id: str, **options) -> Document:
|
||||
if not data.startswith(b"\xd0\xcf\x11\xe0\xa1\xb1\x1a\xe1"):
|
||||
raise ParseError("PcbDoc must be an OLE compound document")
|
||||
try:
|
||||
ole = olefile.OleFileIO(
|
||||
io.BytesIO(data), raise_defects=olefile.DEFECT_INCORRECT
|
||||
)
|
||||
except (OSError, ValueError, struct.error) as exc:
|
||||
raise ParseError(f"Invalid PCB container: {exc}") from exc
|
||||
with ole:
|
||||
entries = ole.listdir(streams=True, storages=False)
|
||||
if len(entries) > 1024:
|
||||
raise ParseError("Excessive PCB stream count")
|
||||
streams = [{"name": "/".join(e), "size": ole.get_size(e)} for e in entries]
|
||||
if (
|
||||
any(s["size"] > 32 * 1024 * 1024 for s in streams)
|
||||
or sum(s["size"] for s in streams) > MAX_TOTAL
|
||||
):
|
||||
raise ParseError("PCB stream size limit exceeded")
|
||||
|
||||
def read(name, required=False):
|
||||
if not ole.exists(name):
|
||||
if required:
|
||||
raise ParseError(f"Missing PCB stream {name}")
|
||||
return b""
|
||||
return ole.openstream(name).read()
|
||||
|
||||
header = read("FileHeaderSix", True)
|
||||
hreader = Reader(header, "FileHeaderSix")
|
||||
header_length = hreader.read("I")
|
||||
envelope = hreader.pascal()
|
||||
if header_length != len(envelope):
|
||||
raise ParseError("PCB version string length mismatch")
|
||||
if envelope != "PCB 6.0 Binary File":
|
||||
raise ParseError(f"Unsupported PCB envelope: {envelope[:100]}")
|
||||
board_records = text_records(read("Board6/Data", True), "Board6")
|
||||
if len(board_records) != 1:
|
||||
raise ParseError("Expected exactly one Board6 property record")
|
||||
board = board_records[0]
|
||||
if board.get("KIND") != "Protel_Advanced_PCB" or board.get("VERSION") not in (
|
||||
"5.0",
|
||||
"5.01",
|
||||
"6.0",
|
||||
):
|
||||
raise ParseError(f"Unsupported PCB board version {board.get('VERSION')}")
|
||||
diagnostics = []
|
||||
grouped = {}
|
||||
counts = {}
|
||||
variants = {}
|
||||
objects = []
|
||||
primitives = []
|
||||
layers = {}
|
||||
component_boxes = {}
|
||||
|
||||
def report(code, message, record=None, severity="warning"):
|
||||
key = (code, message)
|
||||
if key in grouped:
|
||||
grouped[key].count += 1
|
||||
return
|
||||
d = Diagnostic(
|
||||
severity=severity, code=code, message=message, record_id=record
|
||||
)
|
||||
diagnostics.append(d)
|
||||
grouped[key] = d
|
||||
|
||||
def ensure_layer(n):
|
||||
key = str(n)
|
||||
if key in layers:
|
||||
return layers[key]
|
||||
if n == 1:
|
||||
name, col, category = "Top Layer", "#d5624e", "copper"
|
||||
elif 2 <= n <= 31:
|
||||
name, col, category = (
|
||||
f"Mid Layer {n - 1}",
|
||||
("#a5bd59", "#b67ac7", "#d19e4d", "#63bdb5")[(n - 2) % 4],
|
||||
"copper",
|
||||
)
|
||||
elif n in SPECIAL:
|
||||
name, col, category = SPECIAL[n]
|
||||
elif 39 <= n <= 54:
|
||||
name, col, category = f"Internal Plane {n - 38}", "#698a68", "plane"
|
||||
elif 57 <= n <= 72 or n >= 0x01020000:
|
||||
name, col, category = (
|
||||
f"Mechanical {n - 56 if n < 100 else n - 0x01020000}",
|
||||
"#8196a8",
|
||||
"mechanical",
|
||||
)
|
||||
else:
|
||||
name, col, category = f"Layer {n}", "#8196a8", "unknown"
|
||||
name = board.get(f"LAYER{n}NAME", name)
|
||||
if n >= 0x01020000:
|
||||
for k, v in board.items():
|
||||
if (
|
||||
k.startswith("LAYERV7_")
|
||||
and k.endswith("LAYERID")
|
||||
and v == str(n)
|
||||
):
|
||||
name = board.get(k[:-7] + "NAME", name)
|
||||
order = (
|
||||
0
|
||||
if n == 0
|
||||
else 1000
|
||||
if n == 81
|
||||
else 900
|
||||
if n == 33
|
||||
else 890
|
||||
if n == 34
|
||||
else 800
|
||||
if n == 74
|
||||
else 700
|
||||
if n == 1
|
||||
else 600
|
||||
if n == 32
|
||||
else 500 - n
|
||||
if 2 <= n <= 31
|
||||
else 850
|
||||
)
|
||||
layer = BoardLayer(
|
||||
id=key,
|
||||
name=name,
|
||||
color=col,
|
||||
category=category,
|
||||
visible=n in (0, 1, 33, 74, 81),
|
||||
order=order,
|
||||
)
|
||||
layers[key] = layer
|
||||
return layer
|
||||
|
||||
def props_records(name):
|
||||
result = text_records(read(name + "/Data"), name)
|
||||
check_count(name, len(result))
|
||||
return result
|
||||
|
||||
def check_count(name, count):
|
||||
counts[name] = count
|
||||
hdr = read(name + "/Header")
|
||||
if len(hdr) == 4 and at(hdr, 0, "I") != count:
|
||||
report(
|
||||
"record_count_mismatch",
|
||||
f"{name}: header declares {at(hdr, 0, 'I')}, decoded {count}",
|
||||
)
|
||||
|
||||
def typed(name, tag, blocks=1):
|
||||
count = 0
|
||||
for idx, parts in binary_records(read(name + "/Data"), name, tag, blocks):
|
||||
count += 1
|
||||
signature = ",".join(str(len(p)) for p in parts)
|
||||
variants.setdefault(name, Counter())[signature] += 1
|
||||
if sum(counts.values()) + count > 100000:
|
||||
raise ParseError("Excessive PCB object count")
|
||||
yield idx, parts
|
||||
check_count(name, count)
|
||||
|
||||
uid_map = {}
|
||||
uid_stream = text_records(read("UniqueIDPrimitiveInformation/Data"), "UniqueIDPrimitiveInformation")
|
||||
for item in uid_stream:
|
||||
key = (item.get("PRIMITIVEOBJECTID", "").lower(), int(item.get("PRIMITIVEINDEX", -1)))
|
||||
if key in uid_map: raise ParseError("Duplicate primitive UID mapping")
|
||||
uid_map[key] = item.get("UNIQUEID", "")
|
||||
|
||||
nets = [
|
||||
Net(id=f"net:{i}", name=p.get("NAME", f"Net {i}"), properties=p)
|
||||
for i, p in enumerate(props_records("Nets6"))
|
||||
]
|
||||
components = []
|
||||
for i, p in enumerate(props_records("Components6")):
|
||||
if not {"X","Y","LAYER"}.issubset(p):
|
||||
raise ParseError(f"Components6:{i}: missing native placement fields")
|
||||
pos = Point(x=unit(p.get("X", "0mil")), y=-unit(p.get("Y", "0mil")))
|
||||
components.append(
|
||||
Component(
|
||||
id=f"Components6:{i}",
|
||||
designator=p.get("SOURCEDESIGNATOR", ""),
|
||||
name=p.get("SOURCELIBREFERENCE", p.get("PATTERN", "")),
|
||||
value=p.get("COMMENT", ""),
|
||||
comment=p.get("COMMENT", ""),
|
||||
description=p.get("SOURCEDESCRIPTION", ""),
|
||||
library_reference=p.get("PATTERN", ""),
|
||||
source_library=p.get("SOURCEFOOTPRINTLIBRARY", ""),
|
||||
position=pos,
|
||||
rotation=float(p.get("ROTATION", 0)),
|
||||
mirrored=layer_number(p.get("LAYER", "TOP")) == 32,
|
||||
bounds=bounds([pos], 1),
|
||||
parameters=p,
|
||||
)
|
||||
)
|
||||
|
||||
if any(not math.isfinite(c.rotation) for c in components):
|
||||
raise ParseError("Non-finite footprint rotation")
|
||||
|
||||
def common(name, index, p, ext=0):
|
||||
if len(p) < 13:
|
||||
raise ParseError(f"{name}:{index}: short primitive header")
|
||||
net, component = at(p, 3, "H"), at(p, 7, "H")
|
||||
if net != 65535 and net >= len(nets):
|
||||
raise ParseError(f"{name}:{index}: invalid net index {net}")
|
||||
if component != 65535 and component >= len(components):
|
||||
raise ParseError(f"{name}:{index}: invalid component index {component}")
|
||||
lid = resolve_layer(p[0], ext)
|
||||
layer = ensure_layer(lid)
|
||||
c = components[component] if component != 65535 else None
|
||||
props = {
|
||||
"LAYER_ID": str(lid),
|
||||
"NET_INDEX": str(net),
|
||||
"NET_NAME": nets[net].name if net != 65535 else "",
|
||||
"COMPONENT_INDEX": str(component),
|
||||
"LOCKED": str(not bool(p[1] & 4)),
|
||||
"FLAGS_1": str(p[1]),
|
||||
"FLAGS_2": str(p[2]),
|
||||
}
|
||||
obj = SchematicObject(
|
||||
id=f"{name}:{index}",
|
||||
kind=name.removesuffix("6").lower(),
|
||||
native_uid=uid_map.get(({"Tracks6":"track","Arcs6":"arc","Pads6":"pad","Vias6":"via","Texts6":"text","Fills6":"fill","Regions6":"region","ShapeBasedRegions6":"region"}.get(name,""),index), ""),
|
||||
record_type={
|
||||
"Tracks6": 4,
|
||||
"Arcs6": 1,
|
||||
"Pads6": 2,
|
||||
"Vias6": 3,
|
||||
"Texts6": 5,
|
||||
"Fills6": 6,
|
||||
"Regions6": 11,
|
||||
"ShapeBasedRegions6": 11,
|
||||
}.get(name, 0),
|
||||
source_stream=name,
|
||||
source_index=index,
|
||||
owner_id=c.id if c else None,
|
||||
component_id=c.id if c else None,
|
||||
layer_id=layer.id,
|
||||
net_id=nets[net].id if net != 65535 else None,
|
||||
position=Point(x=0, y=0),
|
||||
bounds=bounds([]),
|
||||
properties=props,
|
||||
)
|
||||
objects.append(obj)
|
||||
layer.object_count += 1
|
||||
if c:
|
||||
c.object_ids.append(obj.id)
|
||||
return obj, c
|
||||
|
||||
def shape(
|
||||
obj,
|
||||
path,
|
||||
box,
|
||||
fill="none",
|
||||
width=0.0,
|
||||
layer_id=None,
|
||||
role="",
|
||||
stroke=None,
|
||||
fill_rule="nonzero",
|
||||
position=None,
|
||||
text="",
|
||||
rotation=0,
|
||||
mirrored=False,
|
||||
font_size=10,
|
||||
font_family="Arial",
|
||||
bold=False,
|
||||
italic=False,
|
||||
):
|
||||
layer = ensure_layer(
|
||||
int(layer_id if layer_id is not None else obj.layer_id or 0)
|
||||
)
|
||||
col = layer.color
|
||||
primitive = Primitive(
|
||||
id=f"{obj.id}:p{len(obj.primitive_ids)}",
|
||||
kind="text" if text else "path",
|
||||
object_id=obj.id,
|
||||
component_id=obj.component_id,
|
||||
layer_id=layer.id,
|
||||
net_id=obj.net_id,
|
||||
role=role,
|
||||
path=path,
|
||||
bounds=box,
|
||||
stroke=col if stroke is None else stroke,
|
||||
fill=col if fill == "layer" else fill,
|
||||
width=width,
|
||||
fill_rule=fill_rule,
|
||||
position=position or obj.position,
|
||||
text=text,
|
||||
rotation=rotation,
|
||||
mirrored=mirrored,
|
||||
font_size=font_size,
|
||||
font_family=font_family,
|
||||
bold=bold,
|
||||
italic=italic,
|
||||
)
|
||||
if len(primitives) >= 250000: raise ParseError("PCB drawable limit exceeded")
|
||||
primitives.append(primitive)
|
||||
obj.primitive_ids.append(primitive.id)
|
||||
obj.bounds = union([obj.bounds, box]) if len(obj.primitive_ids) > 1 else box
|
||||
if obj.component_id:
|
||||
ci = int(obj.component_id.split(":")[1])
|
||||
components[ci].primitive_ids.append(primitive.id)
|
||||
if layer.category not in ("mechanical", "paste", "mask"):
|
||||
component_boxes.setdefault(obj.component_id, []).append(box)
|
||||
return primitive
|
||||
|
||||
# Outline is extracted from actual Board6 vertices, including rounded corners.
|
||||
outline, board_bounds = property_outline(board)
|
||||
if not outline:
|
||||
raise ParseError("PCB has no supported Board6 outline")
|
||||
outline_obj = SchematicObject(
|
||||
id="Board6:0",
|
||||
kind="board_outline",
|
||||
record_type=0,
|
||||
source_stream="Board6",
|
||||
source_index=0,
|
||||
layer_id="0",
|
||||
position=Point(x=board_bounds.x, y=board_bounds.y),
|
||||
bounds=board_bounds,
|
||||
properties=board,
|
||||
)
|
||||
objects.append(outline_obj)
|
||||
shape(
|
||||
outline_obj, outline, board_bounds, fill="#13312e", width=0.18, role="board"
|
||||
)
|
||||
counts["Board6"] = 1
|
||||
# Preserve polygon definitions as outlines only; the filled copper is read from Regions6.
|
||||
polygons = props_records("Polygons6")
|
||||
for i, p in enumerate(polygons):
|
||||
lid = layer_number(p.get("LAYER", "TOP"))
|
||||
layer = ensure_layer(lid)
|
||||
path, box = property_outline(p)
|
||||
net = int(p.get("NET", 65535))
|
||||
if net != 65535 and not 0 <= net < len(nets): raise ParseError("Invalid polygon net reference")
|
||||
o = SchematicObject(
|
||||
id=f"Polygons6:{i}",
|
||||
kind="polygon_definition",
|
||||
native_uid=p.get("UNIQUEID", ""),
|
||||
record_type=0,
|
||||
source_stream="Polygons6",
|
||||
source_index=i,
|
||||
layer_id=layer.id,
|
||||
net_id=nets[net].id if 0 <= net < len(nets) else None,
|
||||
visible=False,
|
||||
position=Point(x=box.x, y=box.y),
|
||||
bounds=box,
|
||||
properties=p,
|
||||
)
|
||||
objects.append(o)
|
||||
for i, (p,) in typed("Tracks6", 4):
|
||||
obj, c = common("Tracks6", i, p, at(p, 41, "I", 0))
|
||||
a, b = xy(*at(p, 13, "ii")), xy(*at(p, 21, "ii"))
|
||||
w = raw_unit(at(p, 29, "i"))
|
||||
if w < 0:
|
||||
raise ParseError("Negative track width")
|
||||
obj.position = a
|
||||
obj.connection_points = [a, b]
|
||||
obj.kind = "track"
|
||||
obj.properties["WIDTH_MIL"] = str(w * 10)
|
||||
polygon = at(p, 5, "H")
|
||||
obj.properties["POLYGON_INDEX"] = str(polygon)
|
||||
shape(
|
||||
obj,
|
||||
poly_path([a, b]),
|
||||
bounds([a, b], w / 2),
|
||||
width=w,
|
||||
role="polygon_outline" if p[1] & 2 else "track",
|
||||
)
|
||||
for i, (p,) in typed("Arcs6", 1):
|
||||
obj, c = common("Arcs6", i, p, at(p, 52, "I", 0))
|
||||
pos = xy(*at(p, 13, "ii"))
|
||||
r = raw_unit(at(p, 21, "i"))
|
||||
start, end = at(p, 25, "dd")
|
||||
w = raw_unit(at(p, 41, "i"))
|
||||
if w < 0: raise ParseError("Negative arc width")
|
||||
obj.kind = "arc"
|
||||
obj.position = pos
|
||||
obj.properties.update(
|
||||
{
|
||||
"RADIUS_MIL": str(r * 10),
|
||||
"START_ANGLE": str(start),
|
||||
"END_ANGLE": str(end),
|
||||
"WIDTH_MIL": str(w * 10),
|
||||
}
|
||||
)
|
||||
shape(
|
||||
obj,
|
||||
arc_path(pos, r, start, end),
|
||||
Bounds(
|
||||
x=pos.x - r - w / 2,
|
||||
y=pos.y - r - w / 2,
|
||||
width=2 * r + w,
|
||||
height=2 * r + w,
|
||||
),
|
||||
width=w,
|
||||
role="track",
|
||||
)
|
||||
for i, (p,) in typed("Fills6", 6):
|
||||
obj, c = common("Fills6", i, p, at(p, 42, "I", 0))
|
||||
a, b = xy(*at(p, 13, "ii")), xy(*at(p, 21, "ii"))
|
||||
rotation = at(p, 29, "d")
|
||||
center = Point(x=(a.x + b.x) / 2, y=(a.y + b.y) / 2)
|
||||
obj.position = center
|
||||
obj.kind = "fill"
|
||||
path, box = pad_shape(center, abs(b.x - a.x), abs(b.y - a.y), 2, rotation)
|
||||
obj.properties["ROTATION"] = str(rotation)
|
||||
shape(obj, path, box, fill="layer", role="fill")
|
||||
|
||||
# Render saved tessellated regions with exact interior holes. Keep definition streams separate.
|
||||
def regions(name, extended):
|
||||
for i, (p,) in typed(name, 11):
|
||||
obj, c = common(name, i, p)
|
||||
obj.kind = "region"
|
||||
reader = Reader(p, name)
|
||||
reader.take(14)
|
||||
hole_count = reader.read("H")
|
||||
reader.take(2)
|
||||
props = properties(bytes(reader.block()))
|
||||
obj.properties.update(props)
|
||||
n = reader.read("I") + (1 if extended else 0)
|
||||
if n > 50000 or hole_count > 20000:
|
||||
raise ParseError("Excessive region geometry")
|
||||
vertices = []
|
||||
commands = []
|
||||
all_points = []
|
||||
for _ in range(n):
|
||||
if extended:
|
||||
curved = reader.read("B")
|
||||
pos = xy(*reader.read("ii"))
|
||||
center = xy(*reader.read("ii"))
|
||||
r = raw_unit(reader.read("i"))
|
||||
sa, ea = reader.read("dd")
|
||||
vertices.append((pos, curved, center, r, sa, ea))
|
||||
else:
|
||||
vertices.append((xy(*reader.read("dd")), 0, None, 0, 0, 0))
|
||||
for j, (pos, curved, center, r, sa, ea) in enumerate(vertices):
|
||||
commands.append(("M" if j == 0 else "L") + f"{pos.x:.15g},{pos.y:.15g}")
|
||||
all_points.append(pos)
|
||||
if curved and r > 0:
|
||||
arc = arc_path(center, r, sa, ea)
|
||||
if " " in arc:
|
||||
commands.extend(
|
||||
["L" + arc.split(" ", 1)[0][1:], arc.split(" ", 1)[1]]
|
||||
)
|
||||
all_points.extend(
|
||||
[
|
||||
Point(x=center.x - r, y=center.y - r),
|
||||
Point(x=center.x + r, y=center.y + r),
|
||||
]
|
||||
)
|
||||
commands.append("Z")
|
||||
for _ in range(hole_count):
|
||||
count = reader.read("I")
|
||||
if count > 50000:
|
||||
raise ParseError("Excessive region hole points")
|
||||
pts = [xy(*reader.read("dd")) for _ in range(count)]
|
||||
commands.append(poly_path(pts, True))
|
||||
all_points.extend(pts)
|
||||
obj.properties["OUTLINE_PATH"] = " ".join(commands)
|
||||
obj.bounds = bounds(all_points)
|
||||
obj.position = all_points[0] if all_points else Point(x=0, y=0)
|
||||
obj.properties["HOLE_COUNT"] = str(hole_count)
|
||||
obj.properties["VERTEX_COUNT"] = str(n)
|
||||
obj.properties["POLYGON_INDEX"] = str(at(p, 5, "H"))
|
||||
kind = int(props.get("KIND", 0))
|
||||
polygon = at(p, 5, "H")
|
||||
is_cutout = props.get("ISBOARDCUTOUT", "FALSE").upper() == "TRUE"
|
||||
if extended:
|
||||
# When saved fills exist, shape definitions must not cover clearances twice.
|
||||
if polygon != 65535 and not is_cutout:
|
||||
obj.visible = False
|
||||
continue
|
||||
# Standalone shapes are taken from this stream, with circular arcs intact.
|
||||
role = "region"
|
||||
else:
|
||||
if polygon == 65535 and not is_cutout:
|
||||
obj.visible = False
|
||||
continue
|
||||
role = "pour"
|
||||
if kind != 0 and not is_cutout:
|
||||
obj.visible = False
|
||||
report(
|
||||
"region_definition",
|
||||
f"Region kind {kind} is retained as a definition; saved copper holes govern the fill",
|
||||
obj.id,
|
||||
"info",
|
||||
)
|
||||
continue
|
||||
if is_cutout:
|
||||
shape(
|
||||
obj,
|
||||
" ".join(commands),
|
||||
obj.bounds,
|
||||
fill="#08151b",
|
||||
layer_id="0",
|
||||
role="board_cutout",
|
||||
fill_rule="evenodd",
|
||||
)
|
||||
elif len(vertices) >= 3:
|
||||
shape(
|
||||
obj,
|
||||
" ".join(commands),
|
||||
obj.bounds,
|
||||
fill="layer",
|
||||
role=role,
|
||||
fill_rule="evenodd",
|
||||
)
|
||||
if reader.remaining:
|
||||
report(
|
||||
"region_extension",
|
||||
f"{name} contains uninterpreted region extension bytes",
|
||||
obj.id,
|
||||
)
|
||||
|
||||
regions("Regions6", False)
|
||||
regions("ShapeBasedRegions6", True)
|
||||
poured = {o.properties.get("POLYGON_INDEX") for o in objects if o.source_stream == "Regions6" and o.visible}
|
||||
for index, definition in enumerate(polygons):
|
||||
if str(index) not in poured:
|
||||
report("polygon_without_saved_pour", "Polygons6 definition has no decoded saved copper pour; no fill is invented", f"Polygons6:{index}")
|
||||
# Active copper layers are obtained from legacy stack links, not from LAYERSETSCOUNT (view presets).
|
||||
copper_ids = [1]
|
||||
while True:
|
||||
nxt = int(board.get(f"LAYER{copper_ids[-1]}NEXT", "0"))
|
||||
if not nxt: break
|
||||
if nxt in copper_ids or nxt not in range(1,55) or 33 <= nxt <= 38:
|
||||
raise ParseError("Invalid copper stack links")
|
||||
copper_ids.append(nxt)
|
||||
if 32 not in copper_ids:
|
||||
copper_ids = [1,32]
|
||||
report("copper_stack", "No complete layer chain; only explicit top/bottom copper stack supported")
|
||||
for index, lid in enumerate(copper_ids):
|
||||
ensure_layer(lid).order = 700 - index * 10
|
||||
for i, parts in typed("Pads6", 2, 6):
|
||||
name = Reader(parts[0], "Pad name").pascal()
|
||||
p, stack = parts[4], parts[5]
|
||||
if len(p) < 110:
|
||||
raise ParseError("Unsupported short pad geometry")
|
||||
obj, c = common("Pads6", i, p)
|
||||
obj.kind = "pad"
|
||||
pos = xy(*at(p, 13, "ii"))
|
||||
obj.position = pos
|
||||
sizes = [
|
||||
(raw_unit(x), raw_unit(y))
|
||||
for x, y in [at(p, 21, "ii"), at(p, 29, "ii"), at(p, 37, "ii")]
|
||||
]
|
||||
hole = raw_unit(at(p, 45, "i"))
|
||||
if hole < 0: raise ParseError("Negative pad drill diameter")
|
||||
shapes = list(at(p, 49, "BBB"))
|
||||
rotation = at(p, 52, "d")
|
||||
mode = at(p, 62, "B")
|
||||
obj.properties.update(
|
||||
{
|
||||
"PAD_NAME": name,
|
||||
"HOLE_MIL": str(hole * 10),
|
||||
"ROTATION": str(rotation),
|
||||
"PLATED": str(bool(p[60])),
|
||||
"BOTTOM_SIZE_MIL": f"{sizes[2][0]*10:.15g} x {sizes[2][1]*10:.15g}",
|
||||
"MID_SIZE_MIL": f"{sizes[1][0]*10:.15g} x {sizes[1][1]*10:.15g}",
|
||||
"TOP_SIZE_MIL": f"{sizes[0][0] * 10:.15g} x {sizes[0][1] * 10:.15g}",
|
||||
"SHAPE": str(shapes[0]),
|
||||
"PAD_MODE": str(mode),
|
||||
}
|
||||
)
|
||||
target_layers = (
|
||||
copper_ids if int(obj.layer_id) == 74 else [int(obj.layer_id)]
|
||||
)
|
||||
obj.layer_ids = [str(lid) for lid in target_layers]
|
||||
for lid in target_layers:
|
||||
if mode not in (0,1,2) or (mode == 2 and len(stack) < 596):
|
||||
report("pad_stack", "Unsupported pad stack mode or truncated full-stack variant", obj.id)
|
||||
continue
|
||||
which = 0 if lid == 1 else 2 if lid == 32 else 1
|
||||
w, h = sizes[which]
|
||||
s = shapes[which]
|
||||
radius = 0
|
||||
if mode == 2 and len(stack) >= 596 and 2 <= lid <= 30:
|
||||
w, h = (
|
||||
raw_unit(at(stack, 4 * (lid - 2), "i")),
|
||||
raw_unit(at(stack, 116 + 4 * (lid - 2), "i")),
|
||||
)
|
||||
s = stack[232 + lid - 2]
|
||||
stack_index = 0 if lid == 1 else 31 if lid == 32 else lid - 1
|
||||
if mode == 2 and lid > 32:
|
||||
report("pad_plane_stack", "Full-stack pad geometry on internal plane layers is not decoded", obj.id)
|
||||
continue
|
||||
if len(stack) >= 596 and lid <= 32:
|
||||
alt = stack[532 + stack_index]
|
||||
radius = stack[564 + stack_index]
|
||||
if alt in (1, 2, 3, 9):
|
||||
s = alt
|
||||
elif alt != 0:
|
||||
report(
|
||||
"pad_alt_shape",
|
||||
f"Unsupported alternative pad shape {alt}",
|
||||
obj.id,
|
||||
)
|
||||
continue
|
||||
try:
|
||||
path, box = pad_shape(pos, w, h, s, rotation, radius)
|
||||
except ParseError as exc:
|
||||
report("pad_shape", str(exc), obj.id)
|
||||
continue
|
||||
shape(obj, path, box, fill="layer", layer_id=str(lid), role="pad")
|
||||
if hole > 0:
|
||||
hole_shape = stack[262] if len(stack) >= 596 else 0
|
||||
slot = raw_unit(at(stack, 263, "i")) if len(stack) >= 596 else 0
|
||||
hole_angle = at(stack, 267, "d") if len(stack) >= 596 else 0
|
||||
dx = raw_unit(at(stack, 275, "i")) if len(stack) >= 596 else 0
|
||||
dy = -raw_unit(at(stack, 403, "i")) if len(stack) >= 596 else 0
|
||||
hpos = transform_point(Point(x=dx, y=dy), pos, rotation)
|
||||
if hole_shape == 2 and slot > hole:
|
||||
path, box = pad_shape(hpos, slot, hole, 1, rotation + hole_angle)
|
||||
elif hole_shape in (0, 1):
|
||||
path, box = pad_shape(
|
||||
hpos, hole, hole, 2 if hole_shape == 1 else 1, rotation
|
||||
)
|
||||
else:
|
||||
report(
|
||||
"drill_shape",
|
||||
f"Unsupported pad hole shape {hole_shape}",
|
||||
obj.id,
|
||||
)
|
||||
continue
|
||||
drill = shape(
|
||||
obj,
|
||||
path,
|
||||
box,
|
||||
fill="#08151b",
|
||||
layer_id="81",
|
||||
role="drill",
|
||||
stroke="none",
|
||||
)
|
||||
drill.layer_ids = obj.layer_ids
|
||||
for i, (p,) in typed("Vias6", 3):
|
||||
obj, c = common("Vias6", i, p)
|
||||
obj.kind = "via"
|
||||
pos = xy(*at(p, 13, "ii"))
|
||||
obj.position = pos
|
||||
diameter, hole = raw_unit(at(p, 21, "i")), raw_unit(at(p, 25, "i"))
|
||||
if diameter <= 0 or hole < 0:
|
||||
raise ParseError("Invalid via diameter")
|
||||
obj.properties.update(
|
||||
{
|
||||
"DIAMETER_MIL": str(diameter * 10),
|
||||
"HOLE_MIL": str(hole * 10),
|
||||
"START_LAYER": str(at(p, 29, "B")),
|
||||
"END_LAYER": str(at(p, 30, "B")),
|
||||
}
|
||||
)
|
||||
start_layer, end_layer = int(obj.properties["START_LAYER"]), int(obj.properties["END_LAYER"])
|
||||
if start_layer not in copper_ids or end_layer not in copper_ids:
|
||||
raise ParseError("Via span refers to an inactive/unsupported copper layer")
|
||||
a, b = sorted((copper_ids.index(start_layer), copper_ids.index(end_layer)))
|
||||
span = copper_ids[a:b+1]
|
||||
obj.layer_ids = [str(lid) for lid in span]
|
||||
obj.properties["LAYER_SPAN"] = ",".join(obj.layer_ids)
|
||||
diameters = at(p,75,"32i") if len(p) >= 203 else None
|
||||
for lid in span:
|
||||
if diameters and lid > 32 and len(set(diameters)) > 1:
|
||||
report("via_plane_stack", "Nonuniform via diameter on internal plane layers is not decoded", obj.id)
|
||||
continue
|
||||
size = raw_unit(diameters[lid-1]) if diameters and lid <= 32 else diameter
|
||||
if size <= 0: raise ParseError("Invalid per-layer via diameter")
|
||||
shape(obj, ellipse_path(pos.x,pos.y,size/2),bounds([pos],size/2),fill="layer",layer_id=str(lid),role="via")
|
||||
if hole:
|
||||
drill = shape(obj,ellipse_path(pos.x,pos.y,hole/2),bounds([pos],hole/2),fill="#08151b",layer_id="81",role="drill",stroke="none")
|
||||
drill.layer_ids = obj.layer_ids
|
||||
strings = wide_strings(read("WideStrings6/Data"))
|
||||
for i, parts in typed("Texts6", 5, 2):
|
||||
p = parts[0]
|
||||
obj, c = common("Texts6", i, p, at(p, 226, "I", 0))
|
||||
obj.kind = "text"
|
||||
pos = xy(*at(p, 13, "ii"))
|
||||
obj.position = pos
|
||||
height = raw_unit(at(p, 21, "i"))
|
||||
if height < 0: raise ParseError("Negative text height")
|
||||
rotation = at(p, 27, "d")
|
||||
mirror = bool(p[35])
|
||||
font = "Arial"
|
||||
bold = italic = False
|
||||
is_comment = len(p) >= 123 and bool(p[40])
|
||||
is_designator = len(p) >= 123 and bool(p[41])
|
||||
if len(p) >= 123:
|
||||
font = (
|
||||
bytes(p[46:110]).decode("utf-16le", errors="replace").split("\0")[0]
|
||||
or "Arial"
|
||||
)
|
||||
bold = bool(p[44])
|
||||
italic = bool(p[45])
|
||||
legacy = Reader(parts[1], "PCB text").pascal() if parts[1] else ""
|
||||
value = (
|
||||
strings.get(at(p, 115, "I", 4294967295), legacy)
|
||||
if len(p) >= 123
|
||||
else legacy
|
||||
)
|
||||
if c and is_designator:
|
||||
c.designator = value or c.designator
|
||||
if c.parameters.get("NAMEON", "TRUE") == "FALSE":
|
||||
obj.visible = False
|
||||
if c and is_comment:
|
||||
c.value = value
|
||||
c.comment = value
|
||||
if c.parameters.get("COMMENTON", "FALSE") == "FALSE":
|
||||
obj.visible = False
|
||||
obj.properties.update(
|
||||
{
|
||||
"TEXT_HEIGHT": str(height),
|
||||
"TEXT_MIRRORED": str(mirror),
|
||||
"TEXT": value,
|
||||
"ROTATION": str(rotation),
|
||||
"FONT_NAME": font,
|
||||
"DESIGNATOR": str(is_designator),
|
||||
"COMMENT": str(is_comment),
|
||||
}
|
||||
)
|
||||
if not obj.visible or not value:
|
||||
continue
|
||||
if len(p) > 123 and (p[43] == 2 or p[110] or p[123]):
|
||||
report(
|
||||
"text_style",
|
||||
"PCB barcode/inverted text is approximated by readable text",
|
||||
obj.id,
|
||||
)
|
||||
# Native font substitution is explicitly diagnosed below. Geometry remains vector text.
|
||||
size = max(height * 1.35, 0.01)
|
||||
w = max(size * 0.5, len(value) * size * 0.75)
|
||||
corners = [
|
||||
transform_point(Point(x=x, y=y), pos, rotation, mirror)
|
||||
for x, y in [(0, 0), (w, 0), (w, -size), (0, -size)]
|
||||
]
|
||||
shape(
|
||||
obj,
|
||||
"",
|
||||
bounds(corners),
|
||||
position=pos,
|
||||
text=value,
|
||||
rotation=-rotation,
|
||||
mirrored=mirror,
|
||||
font_size=size,
|
||||
font_family=font,
|
||||
bold=bold,
|
||||
italic=italic,
|
||||
role="text",
|
||||
)
|
||||
for c in components:
|
||||
if c.id in component_boxes:
|
||||
c.bounds = union(component_boxes[c.id])
|
||||
if not c.primitive_ids:
|
||||
report(
|
||||
"empty_footprint",
|
||||
f"No supported 2D geometry for {c.designator or c.id}",
|
||||
)
|
||||
if len(primitives) > 250000:
|
||||
raise ParseError("PCB drawable limit exceeded")
|
||||
# Group layers once; stable within-layer priority keeps pours behind tracks/pads and drills above all.
|
||||
priority = {
|
||||
"board": 0,
|
||||
"pour": 1,
|
||||
"region": 2,
|
||||
"fill": 3,
|
||||
"track": 4,
|
||||
"polygon_outline": 4,
|
||||
"pad": 5,
|
||||
"via": 6,
|
||||
"text": 7,
|
||||
"board_cutout": 8,
|
||||
"drill": 9,
|
||||
}
|
||||
primitives.sort(
|
||||
key=lambda p: (
|
||||
1001 if p.role == "board_cutout" else layers[p.layer_id].order,
|
||||
priority.get(p.role, 4),
|
||||
)
|
||||
)
|
||||
supported = {
|
||||
"FileHeader",
|
||||
"FileHeaderSix",
|
||||
"UniqueIDPrimitiveInformation",
|
||||
"Board6",
|
||||
"Components6",
|
||||
"Nets6",
|
||||
"Tracks6",
|
||||
"Arcs6",
|
||||
"Pads6",
|
||||
"Vias6",
|
||||
"Fills6",
|
||||
"Texts6",
|
||||
"WideStrings6",
|
||||
"Regions6",
|
||||
"ShapeBasedRegions6",
|
||||
"Polygons6",
|
||||
}
|
||||
omitted = [
|
||||
s["name"]
|
||||
for s in streams
|
||||
if s["size"]
|
||||
and s["name"].endswith("/Data")
|
||||
and s["name"].split("/")[0] not in supported
|
||||
]
|
||||
if omitted:
|
||||
for stream in omitted:
|
||||
header = read(stream.removesuffix('/Data')+'/Header')
|
||||
count = at(header,0,'I') if len(header)==4 else 1
|
||||
report("uninterpreted_stream", f"Not interpreted for this 2D viewer: {stream}", stream)
|
||||
grouped[("uninterpreted_stream",f"Not interpreted for this 2D viewer: {stream}")].count = count
|
||||
report(
|
||||
"embedded_resources",
|
||||
"Embedded STEP/3D models and fonts are not loaded or executed. This is a 2D PCB viewer.",
|
||||
"",
|
||||
"info",
|
||||
)
|
||||
report(
|
||||
"pcb_text_metrics",
|
||||
"PCB stroke/TrueType text uses browser font substitution; glyph metrics and special text styles can differ.",
|
||||
)
|
||||
report(
|
||||
"pcb_colors",
|
||||
"Layer colors use a viewer palette; stored layer identity, geometry, net and footprint ownership are retained.",
|
||||
"",
|
||||
"info",
|
||||
)
|
||||
report(
|
||||
"pcb_scope",
|
||||
"Saved copper fills are shown without repouring or executing design rules. Negative internal planes, mask-rule expansions and 3D bodies are not reconstructed.",
|
||||
)
|
||||
return Document(
|
||||
schema_version="1.1",
|
||||
document_type="pcb",
|
||||
id=document_id,
|
||||
filename=filename,
|
||||
metadata={
|
||||
"format": "ole",
|
||||
"header": envelope,
|
||||
"minor_version": board.get("VERSION"),
|
||||
"sha256": hashlib.sha256(data).hexdigest(),
|
||||
"input_bytes": len(data),
|
||||
"streams": streams,
|
||||
"record_counts": counts,
|
||||
"record_count": sum(counts.values()),
|
||||
"object_count": len(objects),
|
||||
"record_variants": {k: dict(v) for k, v in variants.items()},
|
||||
"component_count": len(components),
|
||||
"primitive_count": len(primitives),
|
||||
"units": "10mil",
|
||||
"coordinates": "PCB integer /100000; X right, Y down",
|
||||
"compatibility": "partial",
|
||||
"header_properties": {
|
||||
"ENVELOPE": envelope,
|
||||
"BOARD_VERSION": board.get("VERSION", ""),
|
||||
},
|
||||
"net_count": len(nets),
|
||||
"copper_stack": [str(lid) for lid in copper_ids],
|
||||
"palette_source": "viewer",
|
||||
"board_size_mm": {
|
||||
"width": board_bounds.width * 0.254,
|
||||
"height": board_bounds.height * 0.254,
|
||||
},
|
||||
"uninterpreted_streams": omitted,
|
||||
},
|
||||
sheet=Sheet(
|
||||
name=filename,
|
||||
background="#08151b",
|
||||
bounds=board_bounds,
|
||||
properties=board,
|
||||
),
|
||||
bounds=board_bounds,
|
||||
components=components,
|
||||
symbols=[],
|
||||
objects=objects,
|
||||
primitives=primitives,
|
||||
diagnostics=diagnostics,
|
||||
layers=sorted(layers.values(), key=lambda l: l.order, reverse=True),
|
||||
nets=nets,
|
||||
)
|
||||
@@ -0,0 +1,130 @@
|
||||
"""Two PCB creation endpoints using the existing durable queue and capabilities."""
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
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_pcb
|
||||
from .models.pcb import PCBData, PCBStatus, PCBUploadResponse, PCBComparisonStatus, PCBComparisonUploadResponse
|
||||
|
||||
FRONTEND = Path(__file__).resolve().parents[2] / 'frontend'
|
||||
|
||||
|
||||
def register_pcb_routes(app, *, authorize, require_api, error):
|
||||
def state(job, comparison=False):
|
||||
base = settings().public_url
|
||||
api = f'/api/v1/pcbs/comparisons/{job.id}' if comparison else f'/api/v1/pcbs/{job.id}'
|
||||
view = f'/compare/pcb/{job.id}' if comparison else f'/view/pcb/{job.id}'
|
||||
result = dict(success=job.status != 'failed', id=job.id, status=job.status,
|
||||
document_type='pcb_comparison' if comparison else 'pcb',
|
||||
viewer_url=base+view, status_url=base+api+('' if comparison else '/status'),
|
||||
access=job.access, expires_at=job.expires_at)
|
||||
if comparison: result.update(comparison_id=job.id, url=base+view)
|
||||
if job.summary: result.update(json.loads(job.summary))
|
||||
if job.error_code: result['error'] = dict(code=job.error_code, message=job.error_message)
|
||||
return result
|
||||
|
||||
async def create(files, access, expires_in, comparison):
|
||||
ttl = expires_in or settings().link_ttl
|
||||
if ttl > settings().link_ttl: error('invalid_expiry','expires_in exceeds configured link lifetime',422)
|
||||
sources = {}
|
||||
try:
|
||||
for field, file in files.items(): sources[field] = await read_pcb(file)
|
||||
finally:
|
||||
for file in files.values(): await file.close()
|
||||
identifier = secrets.token_hex(24); token = secrets.token_urlsafe(32); now = time.time()
|
||||
input_name = 'pcb-comparison.json' if comparison else 'input.PcbDoc'
|
||||
job = Job(id=identifier, filename='PCB comparison' if comparison else sources['file'][0],
|
||||
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=f'{identifier}/{input_name}')
|
||||
if comparison:
|
||||
bodies = {f'{identifier}/{side}.PcbDoc':sources[side+'_file'][1] for side in ('old','new')}
|
||||
bodies[job.input_key] = json.dumps(dict(schema_version='1.0',document_type='pcb_comparison',
|
||||
old_filename=sources['old_file'][0],new_filename=sources['new_file'][0])).encode()
|
||||
else: bodies = {job.input_key:sources['file'][1]}
|
||||
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 bodies.items():
|
||||
# Include the attempted key so a write-then-raise backend is also cleaned up.
|
||||
written.append(key)
|
||||
try: storage().put(key,body)
|
||||
except Exception: error('storage_unavailable','Upload storage unavailable',503)
|
||||
db.add(job); db.flush()
|
||||
except Exception:
|
||||
for key in written:
|
||||
try: storage().delete(key)
|
||||
except Exception: pass
|
||||
raise
|
||||
await run_in_threadpool(persist)
|
||||
result = state(job,comparison)
|
||||
if access == 'private':
|
||||
result['viewer_token'] = token; result['viewer_url'] += '#token='+token
|
||||
if comparison: result['url'] = result['viewer_url']
|
||||
return JSONResponse(result,status_code=202,headers={'Location':result['status_url'],'Retry-After':'1'})
|
||||
|
||||
@app.post('/api/v1/pcbs/render', status_code=202, response_model=PCBUploadResponse, response_model_exclude_none=True, tags=['PCB'])
|
||||
async def render(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)
|
||||
return await create({'file':file},access,expires_in,False)
|
||||
|
||||
@app.post('/api/v1/pcbs/comparisons',status_code=202,response_model=PCBComparisonUploadResponse,response_model_exclude_none=True,tags=['PCB'])
|
||||
async def compare(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)
|
||||
return await create({'old_file':old_file,'new_file':new_file},access,expires_in,True)
|
||||
|
||||
def get_job(identifier,key,token,comparison=False):
|
||||
return authorize(identifier,key,token,resource_kind='pcb_comparison' if comparison else 'pcb_render')
|
||||
|
||||
def artifact(job,name):
|
||||
if job.status == 'failed': error(job.error_code,job.error_message,422)
|
||||
if job.status != 'completed': error('not_ready','PCB processing is still in progress',409)
|
||||
try: body = storage().get(job.output_prefix+'/'+name)
|
||||
except Exception: error('storage_unavailable','PCB artifact unavailable',503)
|
||||
return Response(body,media_type='application/json' if name.endswith('.json') else 'image/svg+xml')
|
||||
|
||||
@app.get('/api/v1/pcbs/comparisons/{identifier}',response_model=PCBComparisonStatus,response_model_exclude_none=True,tags=['PCB'])
|
||||
def comparison_status(identifier:str,x_api_key:Annotated[str|None,Header()]=None,x_viewer_token:Annotated[str|None,Header()]=None):
|
||||
job=get_job(identifier,x_api_key,x_viewer_token,True); result=state(job,True)
|
||||
if job.status == 'completed':
|
||||
try: result.update(json.loads(storage().get(job.output_prefix+'/data.json')))
|
||||
except Exception: error('storage_unavailable','PCB comparison unavailable',503)
|
||||
return result
|
||||
|
||||
@app.get('/api/v1/pcbs/comparisons/{identifier}/svg/{side}',response_class=Response,tags=['PCB'])
|
||||
def comparison_svg(identifier:str,side:Literal['old','new'],x_api_key:Annotated[str|None,Header()]=None,x_viewer_token:Annotated[str|None,Header()]=None):
|
||||
return artifact(get_job(identifier,x_api_key,x_viewer_token,True),side+'.svg')
|
||||
|
||||
@app.get('/api/v1/pcbs/{identifier}/status',response_model=PCBStatus,response_model_exclude_none=True,tags=['PCB'])
|
||||
def status(identifier:str,x_api_key:Annotated[str|None,Header()]=None,x_viewer_token:Annotated[str|None,Header()]=None):
|
||||
return state(get_job(identifier,x_api_key,x_viewer_token))
|
||||
|
||||
@app.get('/api/v1/pcbs/{identifier}/data',response_model=PCBData,tags=['PCB'])
|
||||
def data(identifier:str,x_api_key:Annotated[str|None,Header()]=None,x_viewer_token:Annotated[str|None,Header()]=None):
|
||||
return artifact(get_job(identifier,x_api_key,x_viewer_token),'data.json')
|
||||
|
||||
@app.get('/api/v1/pcbs/{identifier}/svg',response_class=Response,tags=['PCB'])
|
||||
def svg(identifier:str,x_api_key:Annotated[str|None,Header()]=None,x_viewer_token:Annotated[str|None,Header()]=None):
|
||||
return artifact(get_job(identifier,x_api_key,x_viewer_token),'drawing.svg')
|
||||
|
||||
@app.get('/view/pcb/{identifier}',include_in_schema=False)
|
||||
@app.get('/compare/pcb/{identifier}',include_in_schema=False)
|
||||
def page(identifier:str):
|
||||
if not re.fullmatch('[a-f0-9]{48}',identifier): error('not_found','PCB not found',404)
|
||||
return FileResponse(FRONTEND/'pcb.html')
|
||||
@@ -0,0 +1,134 @@
|
||||
"""PCB object comparison; reuses only the format-independent entity matcher."""
|
||||
import math
|
||||
import re
|
||||
from collections import defaultdict
|
||||
from .diff import Entity, digest, match_entities
|
||||
|
||||
TOKEN = re.compile(r'[MLAZ]|[-+]?(?:\d*\.\d+|\d+\.?\d*)(?:[eE][-+]?\d+)?')
|
||||
IGNORE = {'NET_INDEX','COMPONENT_INDEX','POLYGON_INDEX','INDEX','UNIQUEID','SOURCEUNIQUEID',
|
||||
'LOCKED','FLAGS_1','FLAGS_2','OUTLINE_PATH','X','Y','ROTATION','LAYER'}
|
||||
|
||||
|
||||
def number(n):
|
||||
return round(float(n), 7) or 0.0
|
||||
|
||||
|
||||
def point(x, y, origin, rotation=0, mirrored=False):
|
||||
x,y = x-origin[0],y-origin[1]
|
||||
a=math.radians(rotation)
|
||||
x,y=x*math.cos(a)-y*math.sin(a),x*math.sin(a)+y*math.cos(a)
|
||||
return (number(-x if mirrored else x),number(y))
|
||||
|
||||
|
||||
def path_signature(path, origin, rotation=0, mirrored=False):
|
||||
"""Canonical local geometry for the M/L/A/Z vocabulary emitted by the PCB parser."""
|
||||
tokens=TOKEN.findall(path); out=[]; i=0
|
||||
while i<len(tokens):
|
||||
cmd=tokens[i]; i+=1
|
||||
if cmd in ('M','L'):
|
||||
out.append((cmd,*point(float(tokens[i]),float(tokens[i+1]),origin,rotation,mirrored)));i+=2
|
||||
elif cmd=='A':
|
||||
rx,ry,angle,large,sweep,x,y=map(float,tokens[i:i+7]);i+=7
|
||||
angle=(angle+rotation)%180
|
||||
if mirrored: angle=(-angle)%180;sweep=1-sweep
|
||||
out.append(('A',number(rx),number(ry),number(angle),int(large),int(sweep),*point(x,y,origin,rotation,mirrored)))
|
||||
elif cmd=='Z':out.append(('Z',))
|
||||
else: raise ValueError('Unexpected PCB SVG path command')
|
||||
# A straight track has no electrical direction.
|
||||
if len(out)==2 and out[0][0]=='M' and out[1][0]=='L':
|
||||
ends=sorted([out[0][1:],out[1][1:]])
|
||||
out=[('M',*ends[0]),('L',*ends[1])]
|
||||
return out
|
||||
|
||||
|
||||
def entities(doc):
|
||||
primitive_map={p.id:p for p in doc.primitives};children=defaultdict(list)
|
||||
for o in doc.objects:
|
||||
if o.component_id:children[o.component_id].append(o)
|
||||
|
||||
def properties(o,origin,rotation=0,mirrored=False):
|
||||
p={k:v for k,v in o.properties.items() if k not in IGNORE and not k.endswith('_INDEX')}
|
||||
# Region definitions may be invisible; their real retained boundary still participates.
|
||||
if 'OUTLINE_PATH' in o.properties:
|
||||
p['outline']=path_signature(o.properties['OUTLINE_PATH'],origin,rotation,mirrored)
|
||||
if 'ROTATION' in o.properties:p['rotation']=number((float(o.properties['ROTATION'])-rotation)%360)
|
||||
if o.kind=='board_outline':p={}
|
||||
if o.kind=='polygon_definition':
|
||||
p={k:v for k,v in p.items() if not re.fullmatch(r'(?:VX|VY|CX|CY|SA|EA|R|KIND)\d+',k) and k!='NET'}
|
||||
p['net']=next((n.name for n in doc.nets if n.id==o.net_id),None)
|
||||
from .parser.pcb_geometry import property_outline
|
||||
p['outline']=path_signature(property_outline(o.properties)[0],origin,rotation,mirrored)
|
||||
geometry=[]
|
||||
for pid in o.primitive_ids:
|
||||
primitive=primitive_map[pid]
|
||||
item=dict(kind=primitive.kind,layer=primitive.layer_id,layers=getattr(primitive,'layer_ids',[]),
|
||||
width=primitive.width,fill_rule=primitive.fill_rule,role=primitive.role,
|
||||
net=next((n.name for n in doc.nets if n.id==primitive.net_id),None))
|
||||
if primitive.kind=='path':item['path']=path_signature(primitive.path,origin,rotation,mirrored)
|
||||
else:item.update(text=primitive.text,position=point(primitive.position.x,primitive.position.y,origin,rotation,mirrored),
|
||||
rotation=number((primitive.rotation+rotation)%360),mirrored=primitive.mirrored != mirrored,
|
||||
font=primitive.font_family,size=primitive.font_size,bold=primitive.bold,italic=primitive.italic)
|
||||
geometry.append(item)
|
||||
p['geometry']=sorted(geometry,key=digest)
|
||||
p['visible']=o.visible
|
||||
return p
|
||||
|
||||
result=[]
|
||||
for c in doc.components:
|
||||
origin=(c.position.x,c.position.y)
|
||||
p={k:v for k,v in c.parameters.items() if k not in IGNORE}
|
||||
p.update(designator=c.designator,pattern=c.library_reference,value=c.value,
|
||||
rotation=number(c.rotation%360),mirrored=c.mirrored)
|
||||
p['children']=sorted([dict(kind=o.kind,properties=properties(o,origin,c.rotation,c.mirrored))
|
||||
for o in children[c.id]],key=digest)
|
||||
result.append(Entity(c.id,'footprint',c.parameters.get('UNIQUEID',c.parameters.get('SOURCEUNIQUEID','')),
|
||||
c.designator,origin,c.bounds.model_dump(),p,digest(p),c.primitive_ids,0))
|
||||
for o in doc.objects:
|
||||
if o.component_id:continue
|
||||
origin=(o.position.x,o.position.y)
|
||||
if o.kind=='track' and o.connection_points:
|
||||
origin=min((p.x,p.y) for p in o.connection_points)
|
||||
p=properties(o,origin)
|
||||
result.append(Entity(o.id,o.kind,getattr(o,'native_uid',''),'',origin,
|
||||
o.bounds.model_dump() if o.primitive_ids else None,p,digest(p),o.primitive_ids,o.record_type))
|
||||
# The layer stack is independent of source record order and object counts.
|
||||
layer_props={k:v for k,v in doc.sheet.properties.items() if re.fullmatch(r'LAYER\d+(?:NAME|PREV|NEXT)|LAYERV7_\d+(?:NAME|LAYERID|PREV|NEXT)',k)}
|
||||
layer_props['copper_stack']=doc.metadata.get('copper_stack',[])
|
||||
result.append(Entity('pcb-layer-stack','layer_stack','board-layer-stack','',(0,0),None,layer_props,digest(layer_props),[],0))
|
||||
return result
|
||||
|
||||
|
||||
def compare_pcbs(old,new):
|
||||
if old.document_type!='pcb' or new.document_type!='pcb':raise ValueError('PCB documents required')
|
||||
before,after=entities(old),entities(new)
|
||||
pairs,removed,added,ambiguous=match_entities(before,after);changes=[]
|
||||
def change(status,a,b,method='',fields=None):
|
||||
e=b or a
|
||||
changes.append(dict(id=f'change-{len(changes)}',status=status,kind=e.kind,
|
||||
label=e.properties.get('designator') or e.properties.get('PAD_NAME') or e.properties.get('TEXT') or e.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=dict(dx=number(b.position[0]-a.position[0]),dy=number(b.position[1]-a.position[1])) if a and b else None,
|
||||
rotation_change=number(b.properties.get('rotation',0)-a.properties.get('rotation',0)) if a and b else None,
|
||||
fields=fields or []))
|
||||
for a,b,method in pairs:
|
||||
fields=[dict(property=k,old=a.properties.get(k),new=b.properties.get(k)) for k in sorted(a.properties.keys()|b.properties.keys()) if a.properties.get(k)!=b.properties.get(k)]
|
||||
if fields or a.position!=b.position:change('modified' if fields else 'moved',a,b,method,fields)
|
||||
for e in removed:change('removed',e,None)
|
||||
for e in added:change('added',None,e)
|
||||
def info(doc):
|
||||
return dict(filename=doc.filename,sha256=doc.metadata['sha256'],compatibility=doc.metadata['compatibility'],
|
||||
sheet=doc.sheet.bounds.model_dump(),bounds=doc.bounds.model_dump(),layers=[l.model_dump() for l in doc.layers],
|
||||
diagnostics=[d.model_dump() for d in doc.diagnostics])
|
||||
a={n.name for n in old.nets};b={n.name for n in new.nets}
|
||||
return dict(schema_version='1.0',document_type='pcb_comparison',old=info(old),new=info(new),changes=changes,
|
||||
summary={s:sum(c['status']==s for c in changes) for s in ('added','removed','modified','moved')},total_changed=len(changes),
|
||||
nets=dict(added=sorted(b-a),removed=sorted(a-b)),
|
||||
matching=dict(matched=len(pairs),unmatched_old=len(removed),unmatched_new=len(added),ambiguous_groups=ambiguous),
|
||||
limitations=['PCB object/net-assignment comparison; no DRC or proof of electrical equivalence.',
|
||||
'Ambiguous identities remain added/removed; non-UID matches are heuristic.',
|
||||
'Footprint children are compared locally; independent routes remain separate.',
|
||||
'Unsupported native streams are not compared; inspect both parser diagnostic lists.',
|
||||
'Saved pours may be stale; no repouring or negative-plane reconstruction.'])
|
||||
@@ -0,0 +1,42 @@
|
||||
"""PCB render and comparison inside the existing parser resource boundary."""
|
||||
from . import parse_task # Apply CPU/address-space/output/FD limits before parsing.
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from .documents import parse_document
|
||||
from .pcb_diff import compare_pcbs
|
||||
from .parser.record_decoder import ParseError
|
||||
from .render import svg_document
|
||||
|
||||
|
||||
def run():
|
||||
source,destination,filename,identifier,kind=sys.argv[1:]
|
||||
dest=Path(destination)
|
||||
try:
|
||||
if kind=='pcb_comparison':
|
||||
manifest=json.loads(Path(source).read_text())
|
||||
if manifest.get('schema_version')!='1.0' or manifest.get('document_type')!='pcb_comparison':
|
||||
raise ParseError('Unsupported PCB comparison manifest')
|
||||
old=parse_document((dest/'old.PcbDoc').read_bytes(),manifest['old_filename'],identifier+'-old',document_type='pcb')
|
||||
new=parse_document((dest/'new.PcbDoc').read_bytes(),manifest['new_filename'],identifier+'-new',document_type='pcb')
|
||||
report=compare_pcbs(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']:dict(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+'-'))
|
||||
summary=dict(summary=report['summary'],total_changed=report['total_changed'])
|
||||
elif kind=='pcb_render':
|
||||
doc=parse_document(Path(source).read_bytes(),filename,identifier,document_type='pcb')
|
||||
(dest/'data.json').write_text(doc.model_dump_json(),encoding='utf-8')
|
||||
(dest/'drawing.svg').write_bytes(svg_document(doc))
|
||||
summary={k:doc.metadata[k] for k in ('compatibility','record_count','component_count','primitive_count','net_count')}
|
||||
summary.update(schema_version=doc.schema_version,diagnostics=[d.model_dump() for d in doc.diagnostics],layers=[l.model_dump() for l in doc.layers])
|
||||
else:raise ParseError('Unsupported PCB job kind')
|
||||
result=dict(ok=True,summary=summary)
|
||||
except (ParseError,ValueError) as exc:
|
||||
result=dict(ok=False,code='invalid_pcb',message=str(exc)[:500])
|
||||
except Exception:
|
||||
result=dict(ok=False,code='pcb_processing_error',message='PCB processing could not be completed')
|
||||
(dest/'result.json').write_text(json.dumps(result),encoding='utf-8')
|
||||
|
||||
if __name__=='__main__':run()
|
||||
@@ -11,8 +11,10 @@ def node(parent, tag, **attrs):
|
||||
|
||||
def svg_document(doc: Document, annotations=None, id_prefix="") -> bytes:
|
||||
b = doc.sheet.bounds
|
||||
pcb = doc.document_type == 'pcb'
|
||||
layer_visibility = {layer.id: layer.visible for layer in doc.layers}
|
||||
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': 'PCB' if pcb else 'Schematic'})
|
||||
node(root, 'rect', x=b.x, y=b.y, width=b.width, height=b.height, fill=doc.sheet.background)
|
||||
defs = node(root, 'defs')
|
||||
for i, p in enumerate(doc.primitives):
|
||||
@@ -20,6 +22,12 @@ def svg_document(doc: Document, annotations=None, id_prefix="") -> bytes:
|
||||
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 pcb:
|
||||
span = getattr(p, "layer_ids", [])
|
||||
visible = layer_visibility.get(p.layer_id, True) and (p.role != "drill" or not span or any(layer_visibility.get(lid, False) for lid in span))
|
||||
target = node(target, 'g', data_layer_id=p.layer_id or '',
|
||||
data_layer_span=' '.join(getattr(p, 'layer_ids', [])), data_role=p.role,
|
||||
display='inline' if visible else 'none')
|
||||
if p.clip:
|
||||
clip = node(defs, 'clipPath', id=f'{id_prefix}clip-{i}', clipPathUnits='userSpaceOnUse')
|
||||
c = p.clip
|
||||
|
||||
@@ -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|(?:old|new)\.SchDoc|comparison\.json|attempt-[12]/(?:data\.json|drawing\.svg|old\.svg|new\.svg))$')
|
||||
KEY = re.compile(r'^[a-f0-9]{48}/(?:input\.(?:SchDoc|PcbDoc)|(?:old|new)\.(?:SchDoc|PcbDoc)|comparison\.json|pcb-comparison\.json|attempt-[12]/(?:data\.json|drawing\.svg|old\.svg|new\.svg))$')
|
||||
|
||||
class Storage:
|
||||
def __init__(self):
|
||||
|
||||
@@ -3,18 +3,36 @@ from fastapi import HTTPException
|
||||
from .config import settings
|
||||
from .parser.record_decoder import ParseError,detect_format
|
||||
|
||||
async def read_schematic(file):
|
||||
async def read_native(file, document_type):
|
||||
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'})
|
||||
suffix = '.PcbDoc' if document_type == 'pcb' else '.SchDoc'
|
||||
if not name.lower().endswith(suffix.lower()):
|
||||
raise HTTPException(415,{'code':'unsupported_file_type','message':f'Upload a {suffix} 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
|
||||
from starlette.concurrency import run_in_threadpool
|
||||
from .parser.pcb_container import identify_pcb, contains_pcb
|
||||
try:
|
||||
if document_type == 'pcb':
|
||||
await run_in_threadpool(identify_pcb, bytes(body))
|
||||
else:
|
||||
detect_format(bytes(body))
|
||||
if await run_in_threadpool(contains_pcb, bytes(body)):
|
||||
raise ParseError('PCB files are not accepted by schematic endpoints')
|
||||
except ParseError as exc:
|
||||
raise HTTPException(422,{'code':'invalid_pcb' if document_type == 'pcb' else 'invalid_schematic','message':str(exc)}) from exc
|
||||
return name,bytes(body)
|
||||
finally:await file.close()
|
||||
|
||||
|
||||
async def read_schematic(file):
|
||||
return await read_native(file, "schematic")
|
||||
|
||||
|
||||
async def read_pcb(file):
|
||||
return await read_native(file, "pcb")
|
||||
|
||||
@@ -13,6 +13,7 @@ from .config import settings
|
||||
from .cleanup import cleanup
|
||||
from .db import Job, claim, initialize, sessions
|
||||
from .storage import storage
|
||||
from .job_types import job_kind, comparison_sources
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
stopping = False
|
||||
@@ -23,19 +24,24 @@ def process(job):
|
||||
try:
|
||||
with tempfile.TemporaryDirectory(prefix='schdoc-') as directory:
|
||||
root = Path(directory)
|
||||
source = root / 'input.SchDoc'
|
||||
comparison = job['input_key'].endswith('/comparison.json')
|
||||
kind = job_kind(job['input_key'])
|
||||
if kind is None: raise ValueError('Unknown job input kind')
|
||||
pcb = kind.startswith('pcb_')
|
||||
source = root / ('input.PcbDoc' if pcb else 'input.SchDoc')
|
||||
comparison = kind.endswith('_comparison')
|
||||
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"))
|
||||
for key in comparison_sources(job['input_key']):
|
||||
(root / key.rsplit('/', 1)[-1]).write_bytes(storage().get(key))
|
||||
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.compare_task' if comparison else 'app.parse_task', str(source), str(root), job['filename'], job['id']],
|
||||
task = 'app.pcb_task' if pcb else 'app.compare_task' if comparison else 'app.parse_task'
|
||||
args = [kind] if pcb else []
|
||||
subprocess.run([sys.executable, '-m', task, str(source), str(root), job['filename'], job['id'], *args],
|
||||
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']:
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
"""Tiny CFB writer solely for bounded native-format unit fixtures (never application data)."""
|
||||
import math
|
||||
import struct
|
||||
|
||||
FREE=0xffffffff; END=0xfffffffe; FAT=0xfffffffd
|
||||
|
||||
|
||||
def cfb(streams):
|
||||
entries=[('Root Entry',5,b'',[])]
|
||||
storages={}
|
||||
for name,data in streams.items():
|
||||
parts=name.split('/'); parent=0
|
||||
for part in parts[:-1]:
|
||||
key=(parent,part)
|
||||
if key not in storages:
|
||||
storages[key]=len(entries);entries.append((part,1,b'',[]));entries[parent][3].append(storages[key])
|
||||
parent=storages[key]
|
||||
entries[parent][3].append(len(entries));entries.append((parts[-1],2,data,[]))
|
||||
# This fixture deliberately uses normal >=4096-byte streams, with padding INSIDE native records.
|
||||
assert all(len(e[2])>=4096 for e in entries if e[1]==2)
|
||||
sectors=[];links=[];starts={}
|
||||
def allocate(data):
|
||||
start=len(sectors); count=math.ceil(len(data)/512)
|
||||
for i in range(count):sectors.append(data[i*512:(i+1)*512].ljust(512,b'\0'));links.append(start+i+1 if i+1<count else END)
|
||||
return start
|
||||
for i,e in enumerate(entries):
|
||||
if e[1]==2:starts[i]=allocate(e[2])
|
||||
directory=bytearray(math.ceil(len(entries)/4)*512)
|
||||
for i,(name,kind,data,children) in enumerate(entries):
|
||||
offset=i*128;encoded=(name+'\0').encode('utf-16le');directory[offset:offset+len(encoded)]=encoded
|
||||
struct.pack_into('<HBB',directory,offset+64,len(encoded),kind,1)
|
||||
siblings=[]
|
||||
for parent in entries:
|
||||
if i in parent[3]:siblings=parent[3];break
|
||||
right=siblings[siblings.index(i)+1] if siblings and siblings.index(i)+1<len(siblings) else FREE
|
||||
struct.pack_into('<III',directory,offset+68,FREE,right,children[0] if children else FREE)
|
||||
struct.pack_into('<IQ',directory,offset+116,starts.get(i,END),len(data))
|
||||
dir_start=allocate(directory)
|
||||
fat_id=len(sectors); assert fat_id<128
|
||||
links.append(FAT)
|
||||
fat=struct.pack('<128I',*(links+[FREE]*(128-len(links))))
|
||||
sectors.append(fat)
|
||||
header=bytearray(512);header[:8]=bytes.fromhex('d0cf11e0a1b11ae1')
|
||||
struct.pack_into('<HHHHH',header,24,0x3e,3,0xfffe,9,6)
|
||||
struct.pack_into('<IIIIIIIII',header,40,0,1,dir_start,0,4096,END,0,END,0)
|
||||
struct.pack_into('<109I',header,76,fat_id,*([FREE]*108))
|
||||
return bytes(header)+b''.join(sectors)
|
||||
|
||||
|
||||
def text(props):
|
||||
data=('|'+'|'.join(f'{k}={v}' for k,v in props.items())+'|_PADDING=').encode()
|
||||
data=data.ljust(4091,b' ')+b'\0'
|
||||
return struct.pack('<I',len(data))+data
|
||||
|
||||
|
||||
def record(tag,data):
|
||||
data=bytes(data).ljust(4091,b'\0')
|
||||
return bytes([tag])+struct.pack('<I',len(data))+data
|
||||
|
||||
|
||||
def pcb(*, width=100000, x=100000, net='GND', version='5.01', track=True, corrupt=False):
|
||||
header=b'PCB 6.0 Binary File'
|
||||
board={'KIND':'Protel_Advanced_PCB','VERSION':version,'LAYER1NEXT':'32','LAYER32PREV':'1',
|
||||
'VX0':'0mil','VY0':'0mil','VX1':'100mil','VY1':'0mil','VX2':'100mil','VY2':'100mil','VX3':'0mil','VY3':'100mil'}
|
||||
streams={'FileHeaderSix':(struct.pack('<IB',len(header),len(header))+header).ljust(4096,b'\0'),
|
||||
'Board6/Data':text(board),'Nets6/Data':text({'NAME':net})}
|
||||
if track:
|
||||
p=bytearray(45);p[0]=1
|
||||
struct.pack_into('<HHH',p,3,0,65535,65535)
|
||||
struct.pack_into('<iiiii',p,13,x,100000,x+200000,100000,width)
|
||||
streams['Tracks6/Data']=record(4,p)
|
||||
if corrupt:streams['Tracks6/Data']=b'\x01'+streams['Tracks6/Data'][1:]
|
||||
return cfb(streams)
|
||||
|
||||
|
||||
def rich_pcb():
|
||||
"""Pad stack and blind via records follow the documented V6 field offsets."""
|
||||
import io
|
||||
import olefile
|
||||
with olefile.OleFileIO(io.BytesIO(pcb())) as ole:
|
||||
streams={'/'.join(e):ole.openstream(e).read() for e in ole.listdir()}
|
||||
streams['Components6/Data']=text({'SOURCEDESIGNATOR':'U1','PATTERN':'QFN','SOURCEUNIQUEID':'FOOTPRINT_UID',
|
||||
'X':'10mil','Y':'10mil','LAYER':'TOP','ROTATION':'0'})
|
||||
p=bytearray(110);p[0]=74
|
||||
struct.pack_into('<HHH',p,3,0,65535,0)
|
||||
struct.pack_into('<ii',p,13,100000,100000)
|
||||
struct.pack_into('<iiiiiii',p,21,100000,200000,150000,250000,200000,300000,50000)
|
||||
struct.pack_into('<BBB',p,49,1,2,3);p[60]=1
|
||||
stack=bytearray(3959);stack[262]=2
|
||||
struct.pack_into('<i',stack,263,100000) # slotted hole
|
||||
parts=[b'\x011',b'',b'',b'',bytes(p),bytes(stack)]
|
||||
streams['Pads6/Data']=b'\x02'+b''.join(struct.pack('<I',len(part))+part for part in parts)
|
||||
v=bytearray(203);v[0]=74
|
||||
struct.pack_into('<HHH',v,3,0,65535,65535)
|
||||
struct.pack_into('<iiii',v,13,500000,500000,100000,50000)
|
||||
v[29]=1;v[30]=32;struct.pack_into('<32i',v,75,*([100000]*31+[200000]))
|
||||
streams['Vias6/Data']=record(3,v)
|
||||
streams['UniqueIDPrimitiveInformation/Data']=text({'PRIMITIVEINDEX':'0','PRIMITIVEOBJECTID':'Pad','UNIQUEID':'PAD_UID'})
|
||||
return cfb(streams)
|
||||
@@ -0,0 +1,298 @@
|
||||
import copy
|
||||
import json
|
||||
import os
|
||||
import struct
|
||||
import time
|
||||
from pathlib import Path
|
||||
from xml.etree.ElementTree import fromstring
|
||||
import pytest
|
||||
from sqlalchemy import update
|
||||
from app.documents import parse_document
|
||||
from app.parser.record_decoder import ParseError
|
||||
from app.parser.pcb_binary import Reader, at, binary_records, wide_strings
|
||||
from app.parser.pcb_container import identify_pcb
|
||||
from app.parser.pcb_geometry import unit,raw_unit,pad_shape
|
||||
from app.models.schematic import Point
|
||||
from app.render import svg_document
|
||||
from app.pcb_diff import compare_pcbs
|
||||
from app.db import Job,claim,sessions
|
||||
from app.storage import storage
|
||||
from app.worker import process
|
||||
from pcb_fixture import pcb
|
||||
from test_platform import client,KEY,upload
|
||||
|
||||
|
||||
def parse(raw):return parse_document(raw,'board.PcbDoc','a'*48,document_type='pcb')
|
||||
def create(client,raw,compare=False,**fields):
|
||||
files={'old_file':('old.PcbDoc',raw),'new_file':('new.PcbDoc',fields.pop('new',raw))} if compare else {'file':('board.PcbDoc',raw)}
|
||||
return client.post('/api/v1/pcbs/comparisons' if compare else '/api/v1/pcbs/render',headers={'X-API-Key':KEY},files=files,data=fields)
|
||||
|
||||
|
||||
def test_pcb_units_and_checked_framing():
|
||||
assert raw_unit(100000)==1 and unit('2.54mm')==pytest.approx(10)
|
||||
for bad in ['nan','infinity','999999999mm']:
|
||||
with pytest.raises(ValueError):unit(bad)
|
||||
with pytest.raises(ParseError):Reader(b'\x01').read('I')
|
||||
with pytest.raises(ParseError):at(struct.pack('<d',float('nan')),0,'d')
|
||||
with pytest.raises(ParseError):list(binary_records(b'\x04\xff\xff\xff\x7f','Tracks6',4))
|
||||
value='مقاومت Ω\0'.encode('utf-16le')
|
||||
assert wide_strings(struct.pack('<II',1,len(value))+value)[1]=='مقاومت Ω'
|
||||
for shape in (1,2,3,9):
|
||||
path,box=pad_shape(Point(x=10,y=-20),8,4,shape,90,30)
|
||||
assert path.endswith('Z') and box.width==pytest.approx(4) and box.height==pytest.approx(8)
|
||||
|
||||
|
||||
def test_native_fixture_structure_geometry_and_layers():
|
||||
raw=pcb();identify_pcb(raw);doc=parse(raw)
|
||||
track=next(o for o in doc.objects if o.kind=='track')
|
||||
assert track.connection_points==[Point(x=1,y=-1),Point(x=3,y=-1)]
|
||||
assert track.net_id==doc.nets[0].id and track.properties['NET_NAME']=='GND'
|
||||
assert doc.metadata['copper_stack']==['1','32']
|
||||
tree=fromstring(svg_document(doc))
|
||||
assert tree.attrib['aria-label']=='PCB'
|
||||
assert any(node.attrib.get('data-layer-id')=='1' for node in tree.iter())
|
||||
|
||||
|
||||
@pytest.mark.parametrize('raw',[b'garbage',b'\xd0\xcf\x11\xe0\xa1\xb1\x1a\xe1'+b'\0'*504,pcb(version='9.0')])
|
||||
def test_invalid_envelopes(raw):
|
||||
with pytest.raises(ParseError):identify_pcb(raw)
|
||||
|
||||
|
||||
def test_structural_diff_movement_width_and_no_mutation():
|
||||
old=parse(pcb());same=parse(pcb());before=old.model_dump_json()
|
||||
assert compare_pcbs(old,same)['total_changed']==0
|
||||
moved=compare_pcbs(old,parse(pcb(x=200000)))
|
||||
assert moved['summary']==dict(added=0,removed=0,modified=0,moved=1)
|
||||
assert moved['changes'][0]['movement']==dict(dx=1,dy=0)
|
||||
modified=compare_pcbs(old,parse(pcb(width=200000)))
|
||||
assert modified['summary']['modified']==1
|
||||
assert compare_pcbs(old,parse(pcb(track=False)))['summary']['removed']==1
|
||||
assert compare_pcbs(old,parse(pcb(net='VCC')))['summary']['modified']==1
|
||||
assert old.model_dump_json()==before
|
||||
|
||||
|
||||
@pytest.mark.parametrize('comparison',[False,True])
|
||||
def test_pcb_api_worker_capabilities_expiry_cleanup(client,comparison):
|
||||
response=create(client,pcb(),comparison,new=pcb(width=200000)) if comparison else create(client,pcb())
|
||||
assert response.status_code==202,response.text
|
||||
result=response.json();identifier=result['id'];headers={'X-Viewer-Token':result['viewer_token']}
|
||||
base=f'/api/v1/pcbs/comparisons/{identifier}' if comparison else f'/api/v1/pcbs/{identifier}'
|
||||
status=base if comparison else base+'/status'
|
||||
svg=base+'/svg/old' if comparison else base+'/svg'
|
||||
assert 'summary' not in result and '#token=' in result['viewer_url']
|
||||
assert client.get(status).status_code==404
|
||||
assert client.get(status,headers={'X-Viewer-Token':'wrong'}).status_code==404
|
||||
assert client.get(svg,headers=headers).status_code==409
|
||||
process(claim());result=client.get(status,headers=headers).json()
|
||||
assert result['status']=='completed',result
|
||||
assert client.get(svg,headers=headers).status_code==200
|
||||
if comparison:assert result['summary']['modified']==1
|
||||
else:
|
||||
data=client.get(base+'/data',headers=headers).json()
|
||||
assert data['document_type']=='pcb'
|
||||
for path in (f'/view/pcb/{identifier}',f'/compare/pcb/{identifier}'):
|
||||
response=client.get(path);assert response.status_code==200
|
||||
assert len(response.headers.get_list('content-security-policy'))==1
|
||||
assert "frame-ancestors 'self' https://git.sadentel.net" in response.headers['content-security-policy']
|
||||
assert 'x-frame-options' not in response.headers
|
||||
with sessions().begin() as db:db.execute(update(Job).where(Job.id==identifier).values(expires_at=time.time()-1))
|
||||
assert client.get(status,headers=headers).status_code==410
|
||||
from app.cleanup import cleanup
|
||||
assert cleanup()==1
|
||||
with pytest.raises(FileNotFoundError):storage().get(identifier+('/pcb-comparison.json' if comparison else '/input.PcbDoc'))
|
||||
if comparison:
|
||||
with pytest.raises(FileNotFoundError):storage().get(identifier+'/old.PcbDoc')
|
||||
|
||||
|
||||
@pytest.mark.parametrize('access',['private','unlisted'])
|
||||
def test_all_four_job_kinds_are_isolated_even_for_service_key(client,ascii_schematic,access):
|
||||
from test_comparison import create as schematic_compare
|
||||
schematic=upload(client,ascii_schematic,access=access).json()
|
||||
sch_compare=schematic_compare(client,ascii_schematic,ascii_schematic,access=access).json()
|
||||
board=create(client,pcb(),access=access).json();comparison=create(client,pcb(),True,access=access).json()
|
||||
routes=[lambda id:f'/api/v1/schematics/{id}/status',lambda id:f'/api/v1/comparisons/{id}',
|
||||
lambda id:f'/api/v1/pcbs/{id}/status',lambda id:f'/api/v1/pcbs/comparisons/{id}']
|
||||
for i,item in enumerate([schematic,sch_compare,board,comparison]):
|
||||
for j,route in enumerate(routes):
|
||||
for headers in ({'X-API-Key':KEY},{'X-Viewer-Token':item.get('viewer_token','')},{}):
|
||||
response=client.get(route(item['id']),headers=headers)
|
||||
if i!=j:assert response.status_code==404,(i,j,response.text)
|
||||
elif 'X-API-Key' in headers or access=='unlisted' or headers.get('X-Viewer-Token'):assert response.status_code==200
|
||||
|
||||
|
||||
def test_auth_cors_upload_validation_and_body_limits(client,ascii_schematic):
|
||||
for path in ('/api/v1/pcbs/render','/api/v1/pcbs/comparisons'):
|
||||
assert client.post(path,content=b'invalid multipart').status_code==401
|
||||
preflight=client.options(path,headers={'Origin':'https://git.sadentel.net','Access-Control-Request-Method':'POST','Access-Control-Request-Headers':'x-api-key'})
|
||||
assert preflight.status_code==200 and preflight.headers['access-control-allow-origin']=='https://git.sadentel.net'
|
||||
assert client.post(path,content=b'x'*(4*1048576),headers={'X-API-Key':KEY,'Content-Type':'multipart/form-data; boundary=x'}).status_code==413
|
||||
assert create(client,ascii_schematic).status_code==422
|
||||
assert upload(client,pcb()).status_code==422
|
||||
assert create(client,pcb(version='9.0')).status_code==422
|
||||
assert create(client,pcb(),expires_in=999999999).status_code==422
|
||||
assert client.post('/api/v1/pcbs/render',headers={'X-API-Key':KEY},files={'file':('wrong.SchDoc',pcb())}).status_code==415
|
||||
|
||||
|
||||
def test_failed_decode_and_second_storage_write_cleanup(client,monkeypatch):
|
||||
created=create(client,pcb(corrupt=True)).json();process(claim())
|
||||
result=client.get(created['status_url'],headers={'X-Viewer-Token':created['viewer_token']}).json()
|
||||
assert result['status']=='failed' and result['error']['code']=='invalid_pcb'
|
||||
original=storage().put;keys=[]
|
||||
def fail(key,body,*args):
|
||||
keys.append(key);original(key,body,*args)
|
||||
if len(keys)==2:raise OSError('write acknowledged late')
|
||||
monkeypatch.setattr(storage(),'put',fail)
|
||||
assert create(client,pcb(),True).status_code==503
|
||||
assert all(not storage().path(key).exists() for key in keys)
|
||||
assert claim() is None
|
||||
|
||||
|
||||
@pytest.fixture(scope='module')
|
||||
def real_board():
|
||||
sample=os.getenv('PCBDOC_SAMPLE')
|
||||
if not sample:pytest.skip('Set PCBDOC_SAMPLE to a private real PCB fixture')
|
||||
path=Path(sample)
|
||||
return parse_document(path.read_bytes(),path.name,'a'*48,document_type='pcb')
|
||||
|
||||
|
||||
def test_real_board_and_self_comparison(real_board):
|
||||
doc=real_board
|
||||
assert len(doc.components)>0 and len(doc.primitives)>0
|
||||
assert len({p.id for p in doc.primitives})==len(doc.primitives)
|
||||
assert compare_pcbs(doc,doc)['total_changed']==0
|
||||
assert any(p.role=='pad' for p in doc.primitives)
|
||||
assert any(p.role=='drill' for p in doc.primitives)
|
||||
assert all(set(o.layer_ids)<={layer.id for layer in doc.layers} for o in doc.objects)
|
||||
|
||||
|
||||
def test_pad_stack_slot_via_layers_and_native_ids():
|
||||
from pcb_fixture import rich_pcb
|
||||
doc=parse(rich_pcb());pad=next(o for o in doc.objects if o.kind=='pad');via=next(o for o in doc.objects if o.kind=='via')
|
||||
assert pad.native_uid=='PAD_UID' and pad.component_id==doc.components[0].id
|
||||
assert pad.layer_ids==['1','32'] and via.layer_ids==['1','32']
|
||||
pads=[p for p in doc.primitives if p.object_id==pad.id and p.role=='pad']
|
||||
assert {p.layer_id for p in pads}=={'1','32'}
|
||||
assert next(p for p in pads if p.layer_id=='1').bounds.width==1
|
||||
assert next(p for p in pads if p.layer_id=='32').bounds.width==2
|
||||
slot=next(p for p in doc.primitives if p.object_id==pad.id and p.role=='drill')
|
||||
assert slot.bounds.width==1 and slot.bounds.height==.5 and slot.layer_ids==['1','32']
|
||||
bottom_via=next(p for p in doc.primitives if p.object_id==via.id and p.layer_id=='32')
|
||||
assert bottom_via.bounds.width==2
|
||||
|
||||
|
||||
def test_footprint_move_rotation_and_child_edits_are_one_change():
|
||||
from pcb_fixture import rich_pcb
|
||||
from app.parser.pcb_geometry import pad_shape
|
||||
old=parse(rich_pcb());new=old.model_copy(deep=True)
|
||||
component=new.components[0];component.position.x+=4
|
||||
for o in new.objects:
|
||||
if o.component_id==component.id:o.position.x+=4;o.bounds.x+=4
|
||||
# Paths translated independently from their existing SVG string.
|
||||
for p in new.primitives:
|
||||
if p.component_id==component.id:
|
||||
if p.role=='pad':
|
||||
w,h=(1,2) if p.layer_id=='1' else (2,3)
|
||||
p.path,p.bounds=pad_shape(Point(x=5,y=-1),w,h,1 if p.layer_id=='1' else 3,0)
|
||||
elif p.role=='drill':p.path,p.bounds=pad_shape(Point(x=5,y=-1),1,.5,1,0)
|
||||
# Source parameters retain physical placement too.
|
||||
component.parameters['X']='50mil'
|
||||
result=compare_pcbs(old,new)
|
||||
assert result['summary']==dict(added=0,removed=0,modified=0,moved=1),result['changes']
|
||||
component.value='new value'
|
||||
result=compare_pcbs(old,new)
|
||||
assert result['summary']['modified']==1 and result['changes'][0]['moved']
|
||||
# Reparse native revision with a rotated footprint/pad: only one footprint modification.
|
||||
rotated=parse(rich_pcb());rotated.components[0].rotation=90
|
||||
assert compare_pcbs(old,rotated)['summary']['modified']==1
|
||||
|
||||
|
||||
def test_record_reordering_net_renumber_and_duplicate_ambiguity():
|
||||
import io
|
||||
import olefile
|
||||
from pcb_fixture import cfb,text,record
|
||||
def native(reverse):
|
||||
with olefile.OleFileIO(io.BytesIO(pcb())) as ole:streams={'/'.join(e):ole.openstream(e).read() for e in ole.listdir()}
|
||||
streams['Nets6/Data']=text({'NAME':'VCC'})+text({'NAME':'GND'}) if reverse else text({'NAME':'GND'})+text({'NAME':'VCC'})
|
||||
records=[]
|
||||
for x,net in [(100000,0),(900000,1)]:
|
||||
p=bytearray(45);p[0]=1
|
||||
struct.pack_into('<HHH',p,3,(1-net) if reverse else net,65535,65535)
|
||||
struct.pack_into('<iiiii',p,13,x,100000,x+200000,100000,100000)
|
||||
records.append(record(4,p))
|
||||
streams['Tracks6/Data']=b''.join(reversed(records) if reverse else records)
|
||||
return parse(cfb(streams))
|
||||
assert compare_pcbs(native(False),native(True))['total_changed']==0
|
||||
old=native(False);new=old.model_copy(deep=True)
|
||||
track=next(o for o in new.objects if o.kind=='track').model_copy(deep=True);track.id+='-duplicate';new.objects.append(track)
|
||||
assert compare_pcbs(old,new)['summary']['added']==1
|
||||
|
||||
|
||||
@pytest.mark.parametrize('path',['/view/pcb/invalid','/compare/pcb/invalid'])
|
||||
def test_pcb_error_page_csp(client,path):
|
||||
r=client.get(path);assert r.status_code==404
|
||||
assert len(r.headers.get_list('content-security-policy'))==1
|
||||
assert "frame-ancestors 'self' https://git.sadentel.net" in r.headers['content-security-policy']
|
||||
assert 'x-frame-options' not in r.headers
|
||||
|
||||
|
||||
def test_pcb_admission_limit_and_stale_attempt_fencing(client,monkeypatch):
|
||||
from app.config import settings
|
||||
monkeypatch.setenv('MAX_PENDING_JOBS','1');settings.cache_clear()
|
||||
result=create(client,pcb(),access='unlisted').json()
|
||||
assert create(client,pcb()).status_code==503
|
||||
old=claim()
|
||||
with sessions().begin() as db:db.execute(update(Job).where(Job.id==result['id']).values(lease_until=time.time()-1))
|
||||
new=claim();assert new['attempt']==2
|
||||
process(new);process(old)
|
||||
with sessions()() as db:
|
||||
job=db.get(Job,result['id']);assert job.status=='completed' and job.output_prefix.endswith('/attempt-2')
|
||||
|
||||
|
||||
def test_cutout_is_not_dropped_as_an_unowned_region():
|
||||
import io
|
||||
import olefile
|
||||
from pcb_fixture import cfb,record
|
||||
with olefile.OleFileIO(io.BytesIO(pcb())) as ole:streams={'/'.join(e):ole.openstream(e).read() for e in ole.listdir()}
|
||||
p=bytearray(18);p[0]=1;struct.pack_into('<HHH',p,3,65535,65535,65535)
|
||||
props=b'|KIND=0|ISBOARDCUTOUT=TRUE\0'
|
||||
p+=struct.pack('<I',len(props))+props+struct.pack('<I',3)
|
||||
for x,y in [(200000,200000),(400000,200000),(400000,400000),(200000,400000)]:
|
||||
p+=struct.pack('<Biiiiidd',0,x,y,0,0,0,0.,0.)
|
||||
streams['ShapeBasedRegions6/Data']=record(11,p)
|
||||
doc=parse(cfb(streams));cutout=next(p for p in doc.primitives if p.role=='board_cutout')
|
||||
assert cutout.layer_id=='0' and cutout.bounds.width==2 and cutout.bounds.height==2
|
||||
assert cutout.fill_rule=='evenodd'
|
||||
|
||||
|
||||
def test_pcb_storage_keys_do_not_allow_traversal_or_arbitrary_formats(client):
|
||||
for name in ['input.PcbDoc','old.PcbDoc','new.PcbDoc','pcb-comparison.json','attempt-1/old.svg']:
|
||||
assert storage().path('a'*48+'/'+name).name==name.split('/')[-1]
|
||||
for name in ['../input.PcbDoc','x.PcbDoc','input.PcbDoc.exe','pcb-comparison.json/extra','attempt-3/drawing.svg']:
|
||||
with pytest.raises(ValueError):storage().path('a'*48+'/'+name)
|
||||
|
||||
|
||||
def test_comparison_accepts_each_file_limit_and_invalid_side(client,monkeypatch):
|
||||
from app.config import settings
|
||||
monkeypatch.setenv('MAX_UPLOAD_BYTES','20000');settings.cache_clear()
|
||||
# Each is 18 KiB; combined exceeds one per-file limit and is nevertheless a valid pair.
|
||||
raw=pcb();assert 20000<len(raw)*2 and len(raw)<20000
|
||||
created=create(client,raw,True).json()
|
||||
identifier=created['id'];headers={'X-Viewer-Token':created['viewer_token']}
|
||||
assert client.get(f'/api/v1/pcbs/comparisons/{identifier}/svg/other',headers=headers).status_code==422
|
||||
assert client.get(f'/api/v1/pcbs/comparisons/{identifier}/svg/../old',headers=headers).status_code==404
|
||||
assert create(client,raw+b'x'*20000).status_code==413
|
||||
|
||||
|
||||
@pytest.mark.parametrize('prefix',['/view/pcb/','/compare/pcb/'])
|
||||
@pytest.mark.parametrize('frame_option',['DENY','SAMEORIGIN'])
|
||||
def test_pcb_preview_replaces_duplicate_csp_and_removes_xfo(client,monkeypatch,prefix,frame_option):
|
||||
from fastapi.responses import Response
|
||||
def page(*args,**kwargs):
|
||||
response=Response('preview',media_type='text/html',headers={'X-Frame-Options':frame_option,'Content-Security-Policy':"frame-ancestors 'none'"})
|
||||
response.headers.append('Content-Security-Policy',"frame-ancestors 'none'")
|
||||
return response
|
||||
monkeypatch.setattr('app.pcb_api.FileResponse',page)
|
||||
response=client.get(prefix+'a'*48)
|
||||
assert len(response.headers.get_list('content-security-policy'))==1
|
||||
assert "frame-ancestors 'self' https://git.sadentel.net" in response.headers['content-security-policy']
|
||||
assert 'x-frame-options' not in response.headers
|
||||
@@ -1,19 +1,21 @@
|
||||
"""Protocol-level S3 contract check with botocore's validation stub (no live S3)."""
|
||||
import io
|
||||
import pytest
|
||||
import boto3
|
||||
from botocore.response import StreamingBody
|
||||
from botocore.stub import Stubber
|
||||
from app.storage import Storage
|
||||
|
||||
|
||||
def test_s3_private_round_trip_contract(tmp_path,monkeypatch):
|
||||
@pytest.mark.parametrize('name', ['input.SchDoc', 'input.PcbDoc', 'old.PcbDoc', 'new.PcbDoc', 'pcb-comparison.json', 'attempt-1/old.svg'])
|
||||
def test_s3_private_round_trip_contract(tmp_path,monkeypatch,name):
|
||||
from app.config import settings
|
||||
from app.storage import storage
|
||||
monkeypatch.setenv('STORAGE_DIR',str(tmp_path)); monkeypatch.setenv('S3_BUCKET','test-private')
|
||||
settings.cache_clear(); storage.cache_clear()
|
||||
client=boto3.client('s3',region_name='us-east-1',aws_access_key_id='test',aws_secret_access_key='test')
|
||||
monkeypatch.setattr(boto3,'client',lambda *a,**kw:client)
|
||||
key='a'*48+'/input.SchDoc'; payload=b'actual bytes'
|
||||
key='a'*48+'/'+name; payload=b'actual bytes'
|
||||
with Stubber(client) as stub:
|
||||
stub.add_response('put_object',{}, {'Bucket':'test-private','Key':key,'Body':payload,'ContentType':'application/octet-stream'})
|
||||
stub.add_response('get_object',{'Body':StreamingBody(io.BytesIO(payload),len(payload))},{'Bucket':'test-private','Key':key})
|
||||
|
||||
مرجع در شماره جدید
مسدود کردن کاربر