summaryrefslogtreecommitdiff
path: root/gerbonara
diff options
context:
space:
mode:
Diffstat (limited to 'gerbonara')
-rw-r--r--gerbonara/gerber/aperture_macros/parse.py5
-rw-r--r--gerbonara/gerber/aperture_macros/primitive.py17
-rw-r--r--gerbonara/gerber/apertures.py19
-rw-r--r--gerbonara/gerber/graphic_objects.py23
-rw-r--r--gerbonara/gerber/graphic_primitives.py48
-rw-r--r--gerbonara/gerber/rs274x.py45
-rw-r--r--gerbonara/gerber/tests/image_support.py24
-rw-r--r--gerbonara/gerber/tests/test_rs274x.py48
8 files changed, 158 insertions, 71 deletions
diff --git a/gerbonara/gerber/aperture_macros/parse.py b/gerbonara/gerber/aperture_macros/parse.py
index 00227c6..375bb5b 100644
--- a/gerbonara/gerber/aperture_macros/parse.py
+++ b/gerbonara/gerber/aperture_macros/parse.py
@@ -118,14 +118,15 @@ class ApertureMacro:
primitive_defs = [ prim.to_gerber(unit) for prim in self.primitives ]
return '*\n'.join(comments + variable_defs + primitive_defs)
- def to_graphic_primitives(self, offset, rotation:'radians', parameters : [float], unit=None):
+ def to_graphic_primitives(self, offset, rotation, parameters : [float], unit=None):
variables = dict(self.variables)
for number, value in enumerate(parameters):
if i in variables:
raise SyntaxError(f'Re-definition of aperture macro variable {i} through parameter {value}')
variables[i] = value
- return [ primitive.to_graphic_primitives(offset, rotation, variables, unit) for primitive in self.primitives ]
+ for primitive in self.primitives:
+ yield from primitive.to_graphic_primitives(offset, rotation, variables, unit)
def rotated(self, angle):
dup = copy.deepcopy(self)
diff --git a/gerbonara/gerber/aperture_macros/primitive.py b/gerbonara/gerber/aperture_macros/primitive.py
index b28fdb5..b569637 100644
--- a/gerbonara/gerber/aperture_macros/primitive.py
+++ b/gerbonara/gerber/aperture_macros/primitive.py
@@ -56,7 +56,6 @@ class Primitive:
attrs = ','.join(str(getattr(self, name)).strip('<>') for name in type(self).__annotations__)
return f'<{type(self).__name__} {attrs}>'
- @contextlib.contextmanager
class Calculator:
def __init__(self, instance, variable_binding={}, unit=None):
self.instance = instance
@@ -91,10 +90,10 @@ class Circle(Primitive):
self.rotation = ConstantExpression(0)
def to_graphic_primitives(self, offset, rotation, variable_binding={}, unit=None):
- with self.Calculator(variable_binding, unit) as calc:
+ with self.Calculator(self, variable_binding, unit) as calc:
x, y = gp.rotate_point(calc.x, calc.y, deg_to_rad(calc.rotation) + rotation, 0, 0)
x, y = x+offset[0], y+offset[1]
- return [ gp.Circle(x, y, calc.r, polarity_dark=bool(calc.exposure)) ]
+ return [ gp.Circle(x, y, calc.diameter/2, polarity_dark=bool(calc.exposure)) ]
def dilate(self, offset, unit):
self.diameter += UnitExpression(offset, unit)
@@ -110,7 +109,7 @@ class VectorLine(Primitive):
rotation : Expression
def to_graphic_primitives(self, offset, rotation, variable_binding={}, unit=None):
- with self.Calculator(variable_binding, unit) as calc:
+ with self.Calculator(self, variable_binding, unit) as calc:
center_x = (calc.end_x + calc.start_x) / 2
center_y = (calc.end_y + calc.start_y) / 2
delta_x = calc.end_x - calc.start_x
@@ -137,8 +136,8 @@ class CenterLine(Primitive):
y : UnitExpression
rotation : Expression
- def to_graphic_primitives(self, variable_binding={}, unit=None):
- with self.Calculator(variable_binding, unit) as calc:
+ def to_graphic_primitives(self, offset, rotation, variable_binding={}, unit=None):
+ with self.Calculator(self, variable_binding, unit) as calc:
rotation += deg_to_rad(calc.rotation)
x, y = gp.rotate_point(calc.x, calc.y, rotation, 0, 0)
x, y = x+offset[0], y+offset[1]
@@ -161,7 +160,7 @@ class Polygon(Primitive):
rotation : Expression
def to_graphic_primitives(self, offset, rotation, variable_binding={}, unit=None):
- with self.Calculator(variable_binding, unit) as calc:
+ with self.Calculator(self, variable_binding, unit) as calc:
rotation += deg_to_rad(calc.rotation)
x, y = gp.rotate_point(calc.x, calc.y, rotation, 0, 0)
x, y = x+offset[0], y+offset[1]
@@ -184,7 +183,7 @@ class Thermal(Primitive):
rotation : Expression
def to_graphic_primitives(self, offset, rotation, variable_binding={}, unit=None):
- with self.Calculator(variable_binding, unit) as calc:
+ with self.Calculator(self, variable_binding, unit) as calc:
rotation += deg_to_rad(calc.rotation)
x, y = gp.rotate_point(calc.x, calc.y, rotation, 0, 0)
x, y = x+offset[0], y+offset[1]
@@ -236,7 +235,7 @@ class Outline(Primitive):
return f'{self.code},{self.exposure.to_gerber()},{len(self.coords)//2-1},{coords},{self.rotation.to_gerber()}'
def to_graphic_primitives(self, offset, rotation, variable_binding={}, unit=None):
- with self.Calculator(variable_binding, unit) as calc:
+ with self.Calculator(self, variable_binding, unit) as calc:
bound_coords = [ (calc(x)+offset[0], calc(y)+offset[1]) for x, y in self.coords ]
bound_radii = [None] * len(bound_coords)
diff --git a/gerbonara/gerber/apertures.py b/gerbonara/gerber/apertures.py
index 104b021..b18b7a1 100644
--- a/gerbonara/gerber/apertures.py
+++ b/gerbonara/gerber/apertures.py
@@ -8,13 +8,16 @@ from . import graphic_primitives as gp
def _flash_hole(self, x, y, unit=None):
- if self.hole_rect_h is not None:
+ if getattr(self, 'hole_rect_h', None) is not None:
return [*self.primitives(x, y, unit),
- Rectangle((x, y),
+ gp.Rectangle((x, y),
(self.convert(self.hole_dia, unit), self.convert(self.hole_rect_h, unit)),
rotation=self.rotation, polarity_dark=False)]
+ elif self.hole_dia is not None:
+ return [*self.primitives(x, y, unit),
+ gp.Circle(x, y, self.convert(self.hole_dia/2, unit), polarity_dark=False)]
else:
- return self.primitives(x, y), Circle((x, y), self.hole_dia, polarity_dark=False)
+ return self.primitives(x, y, unit)
def strip_right(*args):
args = list(args)
@@ -246,8 +249,11 @@ class PolygonAperture(Aperture):
rotation : float = 0
hole_dia : Length(float) = None
+ def __post_init__(self):
+ self.n_vertices = int(self.n_vertices)
+
def primitives(self, x, y, unit=None):
- return [ gp.RegularPolygon(x, y, self.convert(diameter, unit), n_vertices, rotation=self.rotation) ]
+ return [ gp.RegularPolygon(x, y, self.convert(self.diameter, unit)/2, self.n_vertices, rotation=self.rotation) ]
def __str__(self):
return f'<{self.n_vertices}-gon aperture d={self.diameter:.3}'
@@ -284,8 +290,9 @@ class ApertureMacroInstance(Aperture):
return self.macro.name
def primitives(self, x, y, unit=None):
- return [ primitive.with_offset(x, y).rotated(self.rotation, cx=0, cy=0)
- for primitive in self.macro.to_graphic_primitives(self.parameters, unit=unit) ]
+ return self.macro.to_graphic_primitives(
+ offset=(x, y), rotation=self.rotation,
+ parameters=self.parameters, unit=unit)
def dilated(self, offset, unit='mm'):
return replace(self, macro=self.macro.dilated(offset, unit))
diff --git a/gerbonara/gerber/graphic_objects.py b/gerbonara/gerber/graphic_objects.py
index 81d68f3..278401c 100644
--- a/gerbonara/gerber/graphic_objects.py
+++ b/gerbonara/gerber/graphic_objects.py
@@ -1,6 +1,6 @@
import math
-from dataclasses import dataclass, KW_ONLY, astuple, replace
+from dataclasses import dataclass, KW_ONLY, astuple, replace, fields
from . import graphic_primitives as gp
from .gerber_statements import *
@@ -28,7 +28,7 @@ class GerberObject:
return replace(self,
**{
f.name: convert(getattr(self, f.name), self.unit, unit)
- for f in fields(self)
+ for f in fields(self) if type(f.type) is Length
})
def _conv(self, value, unit):
@@ -113,8 +113,16 @@ class Region(GerberObject):
self.poly.arc_centers.append(None)
def to_primitives(self, unit=None):
- self.poly.polarity_dark = polarity_dark
- yield self.poly.converted(unit)
+ self.poly.polarity_dark = self.polarity_dark # FIXME: is this the right spot to do this?
+ if unit == self.unit:
+ yield self.poly
+ else:
+ conv_outline = [ (convert(x, self.unit, unit), convert(y, self.unit, unit))
+ for x, y in self.poly.outline ]
+ convert_entry = lambda entry: (entry[0], (convert(entry[1][0], self.unit, unit), convert(entry[1][1], self.unit, unit)))
+ conv_arc = [ None if entry is None else convert_entry(entry) for entry in self.poly.arc_centers ]
+
+ yield gp.ArcPoly(conv_outline, conv_arc)
def to_statements(self, gs):
yield from gs.set_polarity(self.polarity_dark)
@@ -258,7 +266,12 @@ class Arc(GerberObject):
def to_primitives(self, unit=None):
conv = self.converted(unit)
- yield gp.Arc(*astuple(conv)[:7], width=self.aperture.equivalent_width(unit), polarity_dark=self.polarity_dark)
+ yield gp.Arc(x1=conv.x1, y1=conv.y1,
+ x2=conv.x2, y2=conv.y2,
+ cx=conv.cx, cy=conv.cy,
+ clockwise=self.clockwise,
+ width=self.aperture.equivalent_width(unit),
+ polarity_dark=self.polarity_dark)
def to_statements(self, gs):
yield from gs.set_polarity(self.polarity_dark)
diff --git a/gerbonara/gerber/graphic_primitives.py b/gerbonara/gerber/graphic_primitives.py
index 3052322..98b8aa1 100644
--- a/gerbonara/gerber/graphic_primitives.py
+++ b/gerbonara/gerber/graphic_primitives.py
@@ -7,6 +7,7 @@ from dataclasses import dataclass, KW_ONLY, replace
from .gerber_statements import *
+@dataclass
class GraphicPrimitive:
_ : KW_ONLY
polarity_dark : bool = True
@@ -48,8 +49,8 @@ class Circle(GraphicPrimitive):
def bounding_box(self):
return ((self.x-self.r, self.y-self.r), (self.x+self.r, self.y+self.r))
- def to_svg(self):
- return 'circle', (), dict(cx=x, cy=y, r=r)
+ def to_svg(self, tag, color='black'):
+ return tag('circle', cx=self.x, cy=self.y, r=self.r, style=f'fill: {color}')
@dataclass
@@ -73,8 +74,8 @@ class Obround(GraphicPrimitive):
def bounding_box(self):
return self.to_line().bounding_box()
- def to_svg(self):
- return self.to_line().to_svg()
+ def to_svg(self, tag, color='black'):
+ return self.to_line().to_svg(tag, color)
def arc_bounds(x1, y1, x2, y2, cx, cy, clockwise):
@@ -167,7 +168,10 @@ def point_line_distance(l1, l2, p):
x1, y1 = l1
x2, y2 = l2
x0, y0 = p
- return abs((x2-x1)*(y1-y0) - (x1-x0)*(y2-y1))/point_distance(l1, l2)
+ length = point_distance(l1, l2)
+ if math.isclose(length, 0):
+ return point_distance(l1, p)
+ return abs((x2-x1)*(y1-y0) - (x1-x0)*(y2-y1)) / length
def svg_arc(old, new, center, clockwise):
r = point_distance(old, new)
@@ -183,14 +187,14 @@ class ArcPoly(GraphicPrimitive):
# list of (x : float, y : float) tuples. Describes closed outline, i.e. first and last point are considered
# connected.
outline : [(float,)]
- # list of radii of segments, must be either None (all segments are straight lines) or same length as outline.
+ # must be either None (all segments are straight lines) or same length as outline.
# Straight line segments have None entry.
arc_centers : [(float,)] = None
@property
def segments(self):
ol = self.outline
- return itertools.zip_longest(ol, ol[1:] + [ol[0]], self.arc_centers)
+ return itertools.zip_longest(ol, ol[1:] + [ol[0]], self.arc_centers or [])
def bounding_box(self):
bbox = (None, None), (None, None)
@@ -213,7 +217,7 @@ class ArcPoly(GraphicPrimitive):
if len(self.outline) == 0:
return
- yield f'M {outline[0][0]:.6}, {outline[0][1]:.6}'
+ yield f'M {self.outline[0][0]:.6}, {self.outline[0][1]:.6}'
for old, new, arc in self.segments:
if not arc:
yield f'L {new[0]:.6} {new[1]:.6}'
@@ -221,8 +225,8 @@ class ArcPoly(GraphicPrimitive):
clockwise, center = arc
yield svg_arc(old, new, center, clockwise)
- def to_svg(self):
- return 'path', [], {'d': ' '.join(self._path_d())}
+ def to_svg(self, tag, color='black'):
+ return tag('path', d=' '.join(self._path_d()), style=f'fill: {color}')
@dataclass
@@ -237,10 +241,9 @@ class Line(GraphicPrimitive):
r = self.width / 2
return add_bounds(Circle(self.x1, self.y1, r).bounding_box(), Circle(self.x2, self.y2, r).bounding_box())
- def to_svg(self):
- return 'path', [], dict(
- d=f'M {self.x1:.6} {self.y1:.6} L {self.x2:.6} {self.y2:.6}',
- style=f'stroke-width: {self.width:.6}; stroke-linecap: round')
+ def to_svg(self, tag, color='black'):
+ return tag('path', d=f'M {self.x1:.6} {self.y1:.6} L {self.x2:.6} {self.y2:.6}',
+ style=f'stroke: {color}; stroke-width: {self.width:.6}; stroke-linecap: round')
@dataclass
class Arc(GraphicPrimitive):
@@ -272,11 +275,10 @@ class Arc(GraphicPrimitive):
arc = arc_bounds(x1, y1, x2, y2, cx, cy, self.clockwise)
return add_bounds(endpoints, arc) # FIXME add "include_center" switch
- def to_svg(self):
+ def to_svg(self, tag, color='black'):
arc = svg_arc((self.x1, self.y1), (self.x2, self.y2), (self.cx, self.cy), self.clockwise)
- return 'path', [], dict(
- d=f'M {self.x1:.6} {self.y1:.6} {arc}',
- style=f'stroke-width: {self.width:.6}; stroke-linecap: round')
+ return tag('path', d=f'M {self.x1:.6} {self.y1:.6} {arc}',
+ style=f'stroke: {color}, stroke-width: {self.width:.6}; stroke-linecap: round')
def svg_rotation(angle_rad):
return f'rotation({angle_rad/math.pi*180:.4})'
@@ -309,11 +311,11 @@ class Rectangle(GraphicPrimitive):
def center(self):
return self.x + self.w/2, self.y + self.h/2
- def to_svg(self):
+ def to_svg(self, tag, color='black'):
x, y = self.x - self.w/2, self.y - self.h/2
- return 'rect', [], dict(x=x, y=y, w=self.w, h=self.h, transform=svg_rotation(self.rotation))
-
+ return tag('rect', x=x, y=y, w=self.w, h=self.h, transform=svg_rotation(self.rotation), style=f'fill: {color}')
+@dataclass
class RegularPolygon(GraphicPrimitive):
x : float
y : float
@@ -334,6 +336,6 @@ class RegularPolygon(GraphicPrimitive):
def bounding_box(self):
return self.to_arc_poly().bounding_box()
- def to_svg(self):
- return self.to_arc_poly().to_svg()
+ def to_svg(self, tag, color='black'):
+ return self.to_arc_poly().to_svg(tag, color)
diff --git a/gerbonara/gerber/rs274x.py b/gerbonara/gerber/rs274x.py
index 53b4e5e..3ee8c4d 100644
--- a/gerbonara/gerber/rs274x.py
+++ b/gerbonara/gerber/rs274x.py
@@ -31,6 +31,7 @@ import functools
from pathlib import Path
from itertools import count, chain
from io import StringIO
+import textwrap
from .gerber_statements import *
from .cam import CamFile, FileSettings
@@ -41,7 +42,7 @@ from . import graphic_objects as go
from . import apertures
-def convert(self, value, src, dst):
+def convert(value, src, dst):
if src == dst or src is None or dst is None or value is None:
return value
elif dst == 'mm':
@@ -60,16 +61,19 @@ def points_close(a, b):
return math.isclose(a[0], b[0]) and math.isclose(a[1], b[1])
class Tag:
- def __init__(self, name, children=None, **attrs):
- self.name, self.children, self.attrs = name, children, attrs
+ def __init__(self, name, children=None, root=False, **attrs):
+ self.name, self.attrs = name, attrs
+ self.children = children or []
+ self.root = root
def __str__(self):
- opening = ' '.join([self.name] + [f'{key}="{value}"' for key, value in self.attrs.items()])
+ prefix = '<?xml version="1.0" encoding="utf-8"?>\n' if self.root else ''
+ opening = ' '.join([self.name] + [f'{key.replace("__", ":")}="{value}"' for key, value in self.attrs.items()])
if self.children:
- children = '\n'.join(textwrap.indent(str(c), ' ') for c in children)
- return f'<{opening}>\n{children}\n</{self.name}>'
+ children = '\n'.join(textwrap.indent(str(c), ' ') for c in self.children)
+ return f'{prefix}<{opening}>\n{children}\n</{self.name}>'
else:
- return f'<{opening}/>'
+ return f'{prefix}<{opening}/>'
class GerberFile(CamFile):
""" A class representing a single gerber file
@@ -83,12 +87,19 @@ class GerberFile(CamFile):
self.comments = []
self.objects = []
- def to_svg(self, tag=Tag, margin=0, margin_unit='mm', svg_unit='mm'):
+ def to_svg(self, tag=Tag, margin=0, arg_unit='mm', svg_unit='mm', force_bounds=None, color='black'):
- (min_x, min_y), (max_x, max_y) = self.bounding_box(svg_unit)
+ if force_bounds is None:
+ (min_x, min_y), (max_x, max_y) = self.bounding_box(svg_unit)
+ else:
+ (min_x, min_y), (max_x, max_y) = force_bounds
+ min_x = convert(min_x, arg_unit, svg_unit)
+ min_y = convert(min_y, arg_unit, svg_unit)
+ max_x = convert(max_x, arg_unit, svg_unit)
+ max_y = convert(max_y, arg_unit, svg_unit)
if margin:
- margin = convert(margin, margin_unit, svg_unit)
+ margin = convert(margin, arg_unit, svg_unit)
min_x -= margin
min_y -= margin
max_x += margin
@@ -96,13 +107,17 @@ class GerberFile(CamFile):
w, h = max_x - min_x, max_y - min_y
- primitives = [
- [ tag(*prim.to_svg()) for prim in obj.to_primitives(unit=svg_unit) ]
- for obj in self.objects ]
+ primitives = [ prim.to_svg(tag, color) for obj in self.objects for prim in obj.to_primitives(unit=svg_unit) ]
- # FIXME setup viewport transform flipping y axis
+ # setup viewport transform flipping y axis
+ xform = f'scale(0 -1) translate(0 {h})'
- return tag('svg', [defs, *primitives], width=w, height=h, viewBox=f'{min_x} {min_y} {w} {h}')
+ svg_unit = 'in' if svg_unit == 'inch' else 'mm'
+ # TODO export apertures as <uses> where reasonable.
+ return tag('svg', [*primitives],
+ width=f'{w}{svg_unit}', height=f'{h}{svg_unit}',
+ viewBox=f'{min_x} {min_y} {w} {h}', transform=xform,
+ xmlns="http://www.w3.org/2000/svg", xmlns__xlink="http://www.w3.org/1999/xlink", root=True)
def merge(self, other):
""" Merge other GerberFile into this one """
diff --git a/gerbonara/gerber/tests/image_support.py b/gerbonara/gerber/tests/image_support.py
index db44157..dc2cbdb 100644
--- a/gerbonara/gerber/tests/image_support.py
+++ b/gerbonara/gerber/tests/image_support.py
@@ -60,16 +60,16 @@ def run_cargo_cmd(cmd, args, **kwargs):
except FileNotFoundError:
return subprocess.run([str(Path.home() / '.cargo' / 'bin' / cmd), *args], **kwargs)
-def svg_to_png(in_svg, out_png):
- run_cargo_cmd('resvg', ['--dpi', '100', in_svg, out_png], check=True, stdout=subprocess.DEVNULL)
+def svg_to_png(in_svg, out_png, dpi=100):
+ run_cargo_cmd('resvg', ['--dpi', str(dpi), in_svg, out_png], check=True, stdout=subprocess.DEVNULL)
-def gbr_to_svg(in_gbr, out_svg, origin=(0, 0), size=(6, 6)):
+def gerbv_export(in_gbr, out_svg, format='svg', origin=(0, 0), size=(6, 6), fg='#ffffff'):
x, y = origin
w, h = size
- cmd = ['gerbv', '-x', 'svg',
+ cmd = ['gerbv', '-x', format,
'--border=0',
f'--origin={x:.6f}x{y:.6f}', f'--window_inch={w:.6f}x{h:.6f}',
- '--foreground=#ffffff',
+ f'--foreground={fg}',
'-o', str(out_svg), str(in_gbr)]
subprocess.run(cmd, check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
@@ -94,12 +94,16 @@ def cleanup_clips(soup):
# Apart from being graphically broken, this additionally causes very bad rendering performance.
del group['clip-path']
+def cleanup_gerbv_svg(filename):
+ with svg_soup(filename) as soup:
+ cleanup_clips(soup)
+
def gerber_difference(reference, actual, diff_out=None, svg_transform=None, size=(10,10)):
with tempfile.NamedTemporaryFile(suffix='.svg') as act_svg,\
tempfile.NamedTemporaryFile(suffix='.svg') as ref_svg:
- gbr_to_svg(reference, ref_svg.name, size=size)
- gbr_to_svg(actual, act_svg.name, size=size)
+ gerbv_export(reference, ref_svg.name, size=size, format='svg')
+ gerbv_export(actual, act_svg.name, size=size, format='svg')
with svg_soup(ref_svg.name) as soup:
if svg_transform is not None:
@@ -116,9 +120,9 @@ def gerber_difference_merge(ref1, ref2, actual, diff_out=None, composite_out=Non
tempfile.NamedTemporaryFile(suffix='.svg') as ref1_svg,\
tempfile.NamedTemporaryFile(suffix='.svg') as ref2_svg:
- gbr_to_svg(ref1, ref1_svg.name, size=size)
- gbr_to_svg(ref2, ref2_svg.name, size=size)
- gbr_to_svg(actual, act_svg.name, size=size)
+ gerbv_export(ref1, ref1_svg.name, size=size, format='svg')
+ gerbv_export(ref2, ref2_svg.name, size=size, format='svg')
+ gerbv_export(actual, act_svg.name, size=size, format='svg')
with svg_soup(ref1_svg.name) as soup1:
if svg_transform1 is not None:
diff --git a/gerbonara/gerber/tests/test_rs274x.py b/gerbonara/gerber/tests/test_rs274x.py
index 90ccdb9..de06a16 100644
--- a/gerbonara/gerber/tests/test_rs274x.py
+++ b/gerbonara/gerber/tests/test_rs274x.py
@@ -17,7 +17,7 @@ import pytest
from ..rs274x import GerberFile
from ..cam import FileSettings
-from .image_support import gerber_difference, gerber_difference_merge
+from .image_support import *
deg_to_rad = lambda a: a/180 * math.pi
@@ -62,6 +62,30 @@ def temp_files(request):
else:
print(f'gerbv {perm_path_gbr} {reference_path(args["file_a"])} {reference_path(args["file_b"])}')
+@pytest.fixture
+def svg_temp_files(request):
+ with tempfile.NamedTemporaryFile(suffix='.svg') as out_svg,\
+ tempfile.NamedTemporaryFile(suffix='.png') as out_png,\
+ tempfile.NamedTemporaryFile(suffix='.png') as ref_png,\
+ tempfile.NamedTemporaryFile(suffix='.png') as tmp_png:
+ yield Path(out_svg.name), Path(out_png.name), Path(ref_png.name), Path(tmp_png.name)
+
+ if request.node.rep_call.failed:
+ module, _, test_name = request.node.nodeid.rpartition('::')
+ _test, _, test_name = test_name.partition('_')
+ test_name, _, _ext = test_name.partition('.')
+ test_name = re.sub(r'[^\w\d]', '_', test_name)
+ fail_dir.mkdir(exist_ok=True)
+ perm_path_out_svg = fail_dir / f'failure_{test_name}_actual.svg'
+ perm_path_png = fail_dir / f'failure_{test_name}_difference.png'
+ shutil.copy(out_svg.name, perm_path_out_svg)
+ shutil.copy(tmp_png.name, perm_path_png)
+ args = request.node.funcargs
+ print(f'Reference file is {reference_path(args["reference"])}')
+ print(f'Failing output saved to {perm_path_out_svg}')
+ print(f'Difference image saved to {perm_path_png}')
+
+
to_gerbv_svg_units = lambda val, unit='mm': val*72 if unit == 'inch' else val/25.4*72
REFERENCE_FILES = [ l.strip() for l in '''
@@ -284,4 +308,26 @@ def test_compositing(temp_files, file_a, file_b, angle, offset):
assert hist[9] < 100
assert hist[3:].sum() < 1e-3*hist.size
+@pytest.mark.filterwarnings('ignore:Deprecated.*statement found.*:DeprecationWarning')
+@pytest.mark.filterwarnings('ignore::SyntaxWarning')
+@pytest.mark.parametrize('reference', REFERENCE_FILES)
+def test_svg_export(svg_temp_files, reference):
+ ref = reference_path(reference)
+ grb = GerberFile.open(ref)
+ out_svg, out_png, ref_png, tmp_png = svg_temp_files
+
+ bounds = (0.0, 0.0), (6.0, 6.0) # bottom left, top right
+
+ with open(out_svg, 'w') as f:
+ f.write(str(grb.to_svg(force_bounds=bounds, arg_unit='inch')))
+
+ gerbv_export(ref, ref_png, origin=bounds[0], size=bounds[1], format='png', fg='#000000')
+ svg_to_png(out_svg, out_png, dpi=72) # make dpi match Cairo's default
+
+ mean, _max, hist = image_difference(ref_png, out_png, diff_out=tmp_png)
+ assert mean < 1e-3
+ assert hist[9] < 1
+ assert hist[3:].sum() < 1e-3*hist.size
+
+# FIXME test svg margin, bounding box computation