From 74fb384c4c0899f4d6f153da8db748a7a49e78ee Mon Sep 17 00:00:00 2001 From: jaseg Date: Thu, 9 Nov 2023 19:16:37 +0100 Subject: aperture macros: work around gerbv/jlc wonkiness --- gerbonara/aperture_macros/expression.py | 97 +++++++++++++++++++++++++++++---- gerbonara/aperture_macros/parse.py | 80 ++++++++++++++++++--------- gerbonara/aperture_macros/primitive.py | 47 ++++++++++++---- gerbonara/apertures.py | 7 ++- gerbonara/cam.py | 7 +++ gerbonara/layers.py | 2 +- gerbonara/rs274x.py | 4 +- gerbonara/tests/image_support.py | 2 +- gerbonara/tests/test_cli.py | 48 ++++++++-------- 9 files changed, 216 insertions(+), 78 deletions(-) (limited to 'gerbonara') diff --git a/gerbonara/aperture_macros/expression.py b/gerbonara/aperture_macros/expression.py index 0747bf6..c9c0470 100644 --- a/gerbonara/aperture_macros/expression.py +++ b/gerbonara/aperture_macros/expression.py @@ -31,10 +31,13 @@ class Expression: def converted(self, unit): return self + def replace_mixed_subexpressions(self, unit): + return self + def calculate(self, variable_binding={}, unit=None): expr = self.converted(unit).optimized(variable_binding) if not isinstance(expr, ConstantExpression): - raise IndexError(f'Cannot fully resolve expression due to unresolved variables: {expr} with variables {variable_binding}') + raise IndexError(f'Cannot fully resolve expression due to unresolved parameters: residual expression {expr} under parameters {variable_binding}') return expr.value def __add__(self, other): @@ -67,6 +70,13 @@ class Expression: def __pos__(self): return self + def parameters(self): + return tuple() + + @property + def _operator(self): + return None + @dataclass(frozen=True, slots=True) class UnitExpression(Expression): @@ -80,8 +90,8 @@ class UnitExpression(Expression): object.__setattr__(self, 'expr', expr) object.__setattr__(self, 'unit', unit) - def to_gerber(self, unit=None): - return self.converted(unit).optimized().to_gerber() + def to_gerber(self, register_variable=None, unit=None): + return self.converted(unit).optimized().to_gerber(register_variable) def __eq__(self, other): return type(other) == type(self) and \ @@ -94,6 +104,9 @@ class UnitExpression(Expression): def __repr__(self): return f'' + def replace_mixed_subexpressions(self, unit): + return self.converted(unit).replace_mixed_subexpressions(unit) + def converted(self, unit): if self.unit is None or unit is None or self.unit == unit: return self.expr @@ -148,6 +161,10 @@ class UnitExpression(Expression): def __pos__(self): return self + def parameters(self): + return self.expr.parameters() + + @dataclass(frozen=True, slots=True) class ConstantExpression(Expression): value: float @@ -161,12 +178,38 @@ class ConstantExpression(Expression): except TypeError: return False - def to_gerber(self, unit=None): + def to_gerber(self, register_variable=None, unit=None): + if self == 0: # Avoid producing "-0" for negative floating point zeros + return '0' return f'{self.value:.6f}'.rstrip('0').rstrip('.') @dataclass(frozen=True, slots=True) class VariableExpression(Expression): + expr: Expression + + def optimized(self, variable_binding={}): + opt = self.expr.optimized(variable_binding) + if isinstance(opt, OperatorExpression): + return self + else: + return opt + + def __eq__(self, other): + return type(self) == type(other) and self.expr == other.expr + + def replace_mixed_subexpressions(self, unit): + return VariableExpression(self.expr.replace_mixed_subexpressions(unit)) + + def to_gerber(self, register_variable=None, unit=None): + if register_variable is None: + return self.expr.to_gerber(None, unit) + else: + num = register_variable(self.expr.converted(unit).optimized()) + return f'${num}' + +@dataclass(frozen=True, slots=True) +class ParameterExpression(Expression): number: int def optimized(self, variable_binding={}): @@ -178,9 +221,13 @@ class VariableExpression(Expression): return type(self) == type(other) and \ self.number == other.number - def to_gerber(self, unit=None): + def to_gerber(self, register_variable=None, unit=None): return f'${self.number}' + def parameters(self): + yield self + + @dataclass(frozen=True, slots=True) class NegatedExpression(Expression): value: Expression @@ -196,17 +243,24 @@ class NegatedExpression(Expression): # -(x-y) == y-x case OperatorExpression(operator.sub, l, r): return OperatorExpression(operator.sub, r, l) - + # Round very small values and negative floating point zeros to a (positive) zero + case 0: + return expr(0) + # Default case case x: return NegatedExpression(x) + @property + def _operator(self): + return self.value._operator + def __eq__(self, other): return type(self) == type(other) and \ self.value == other.value - def to_gerber(self, unit=None): - val_str = self.value.to_gerber(unit) - if isinstance(self.value, VariableExpression): + def to_gerber(self, register_variable=None, unit=None): + val_str = self.value.to_gerber(register_variable, unit) + if isinstance(self.value, (VariableExpression, ParameterExpression)): return f'-{val_str}' else: return f'-({val_str})' @@ -229,6 +283,10 @@ class OperatorExpression(Expression): self.l == other.l and \ self.r == other.r + @property + def _operator(self): + return self.op + def optimized(self, variable_binding={}): l = self.l.optimized(variable_binding) r = self.r.optimized(variable_binding) @@ -297,10 +355,21 @@ class OperatorExpression(Expression): return OperatorExpression(self.op, l, r) return expr(rv).optimized(variable_binding) + + def replace_mixed_subexpressions(self, unit): + l = self.l.replace_mixed_subexpressions(unit) + if l._operator not in (None, self.op): + l = VariableExpression(self.l) + + r = self.r.replace_mixed_subexpressions(unit) + if r._operator not in (None, self.op): + r = VariableExpression(self.r) + + return OperatorExpression(self.op, l, r) - def to_gerber(self, unit=None): - lval = self.l.to_gerber(unit) - rval = self.r.to_gerber(unit) + def to_gerber(self, register_variable=None, unit=None): + lval = self.l.to_gerber(register_variable, unit) + rval = self.r.to_gerber(register_variable, unit) if isinstance(self.l, OperatorExpression): lval = f'({lval})' @@ -314,3 +383,7 @@ class OperatorExpression(Expression): return f'{lval}{op}{rval}' + def parameters(self): + yield from self.l.parameters() + yield from self.r.parameters() + diff --git a/gerbonara/aperture_macros/parse.py b/gerbonara/aperture_macros/parse.py index 1527bc1..fb4f0fe 100644 --- a/gerbonara/aperture_macros/parse.py +++ b/gerbonara/aperture_macros/parse.py @@ -18,40 +18,47 @@ from ..utils import MM def rad_to_deg(x): return (x / math.pi) * 180 -def _map_expression(node): +def _map_expression(node, variables={}, parameters=set()): if isinstance(node, ast.Num): return ConstantExpression(node.n) elif isinstance(node, ast.BinOp): op_map = {ast.Add: operator.add, ast.Sub: operator.sub, ast.Mult: operator.mul, ast.Div: operator.truediv} - return OperatorExpression(op_map[type(node.op)], _map_expression(node.left), _map_expression(node.right)) + return OperatorExpression(op_map[type(node.op)], + _map_expression(node.left, variables, parameters), + _map_expression(node.right, variables, parameters)) elif isinstance(node, ast.UnaryOp): if type(node.op) == ast.UAdd: - return _map_expression(node.operand) + return _map_expression(node.operand, variables, parameters) else: - return NegatedExpression(_map_expression(node.operand)) + return NegatedExpression(_map_expression(node.operand, variables, parameters)) elif isinstance(node, ast.Name): - return VariableExpression(int(node.id[3:])) # node.id has format var[0-9]+ + num = int(node.id[3:]) # node.id has format var[0-9]+ + if num in variables: + return VariableExpression(variables[num]) + else: + parameters.add(num) + return ParameterExpression(num) else: raise SyntaxError('Invalid aperture macro expression') -def _parse_expression(expr): +def _parse_expression(expr, variables, parameters): expr = expr.lower().replace('x', '*') expr = re.sub(r'\$([0-9]+)', r'var\1', expr) try: parsed = ast.parse(expr, mode='eval').body except SyntaxError as e: raise SyntaxError('Invalid aperture macro expression') from e - return _map_expression(parsed) + return _map_expression(parsed, variables, parameters) @dataclass(frozen=True, slots=True) class ApertureMacro: name: str = field(default=None, hash=False, compare=False) + num_parameters: int = 0 primitives: tuple = () - variables: tuple = () comments: tuple = field(default=(), hash=False, compare=False) def __post_init__(self): @@ -66,6 +73,7 @@ class ApertureMacro: def parse_macro(kls, macro_name, body, unit): comments = [] variables = {} + parameters = set() primitives = [] blocks = body.split('*') @@ -83,19 +91,18 @@ class ApertureMacro: name, expr = block.partition('=') number = int(name[1:]) if number in variables: - raise SyntaxError(f'Re-definition of aperture macro variable {number} inside macro') - variables[number] = _parse_expression(expr) + raise SyntaxError(f'Re-definition of aperture macro variable ${number} inside macro. Previous definition of ${number} was ${variables[number]}.') + variables[number] = _parse_expression(expr, variables, parameters) else: # primitive primitive, *args = block.split(',') - args = [ _parse_expression(arg) for arg in args ] + args = [ _parse_expression(arg, variables, parameters) for arg in args ] primitives.append(ap.PRIMITIVE_CLASSES[int(primitive)].from_arglist(unit, args)) - variables = [variables.get(i+1) for i in range(max(variables.keys(), default=0))] - return kls(macro_name, tuple(primitives), tuple(variables), tuple(comments)) + return kls(macro_name, max(parameters, default=0), tuple(primitives), tuple(comments)) def __str__(self): - return f'' + return f'' def __repr__(self): return str(self) @@ -111,11 +118,32 @@ class ApertureMacro: pass return replace(self, primitives=tuple(new_primitives)) - def to_gerber(self, unit=None): + def to_gerber(self, settings): """ Serialize this macro's content (without the name) into Gerber using the given file unit """ comments = [ f'0 {c.replace("*", "_").replace("%", "_")}' for c in self.comments ] - variable_defs = [ f'${var}={str(expr)[1:-1]}' for var, expr in enumerate(self.variables, start=1) if expr is not None ] - primitive_defs = [ prim.to_gerber(unit) for prim in self.primitives ] + + subexpression_variables = {} + def register_variable(expr): + if not settings.allow_mixed_operators_in_aperture_macros: + expr = expr.replace_mixed_subexpressions(unit=settings.unit) + + expr_str = expr.to_gerber(register_variable, settings.unit) + if expr_str not in subexpression_variables: + subexpression_variables[expr_str] = self.num_parameters + 1 + len(subexpression_variables) + + return subexpression_variables[expr_str] + + primitive_defs = [] + for prim in self.primitives: + if not settings.allow_mixed_operators_in_aperture_macros: + prim = prim.replace_mixed_subexpressions(unit=settings.unit) + + primitive_defs.append(prim.to_gerber(register_variable, settings)) + + variable_defs = [] + for expr_str, num in subexpression_variables.items(): + variable_defs.append(f'${num}={expr_str}') + return '*\n'.join(comments + variable_defs + primitive_defs) def to_graphic_primitives(self, offset, rotation, parameters : [float], unit=None, polarity_dark=True): @@ -138,7 +166,7 @@ class ApertureMacro: primitive.scaled(scale) for primitive in self.primitives)) -var = VariableExpression +var = ParameterExpression deg_per_rad = 180 / math.pi class GenericMacros: @@ -147,16 +175,16 @@ class GenericMacros: # NOTE: All generic macros have rotation values specified in **clockwise radians** like the rest of the user-facing # API. - circle = ApertureMacro('GNC', ( + circle = ApertureMacro('GNC', 4, ( ap.Circle('mm', 1, var(1), 0, 0, var(4) * -deg_per_rad), *_generic_hole(2))) - rect = ApertureMacro('GNR', ( + rect = ApertureMacro('GNR', 5, ( ap.CenterLine('mm', 1, var(1), var(2), 0, 0, var(5) * -deg_per_rad), *_generic_hole(3))) # params: width, height, corner radius, *hole, rotation - rounded_rect = ApertureMacro('GRR', ( + rounded_rect = ApertureMacro('GRR', 6, ( ap.CenterLine('mm', 1, var(1)-2*var(3), var(2), 0, 0, var(6) * -deg_per_rad), ap.CenterLine('mm', 1, var(1), var(2)-2*var(3), 0, 0, var(6) * -deg_per_rad), ap.Circle('mm', 1, var(3)*2, +(var(1)/2-var(3)), +(var(2)/2-var(3)), var(6) * -deg_per_rad), @@ -166,7 +194,7 @@ class GenericMacros: *_generic_hole(4))) # params: width, height, length difference between narrow side (top) and wide side (bottom), *hole, rotation - isosceles_trapezoid = ApertureMacro('GTR', ( + isosceles_trapezoid = ApertureMacro('GTR', 6, ( ap.Outline('mm', 1, 4, (var(1)/-2, var(2)/-2, var(1)/-2+var(3)/2, var(2)/2, @@ -177,14 +205,14 @@ class GenericMacros: *_generic_hole(4))) # params: width, height, length difference between narrow side (top) and wide side (bottom), margin, *hole, rotation - rounded_isosceles_trapezoid = ApertureMacro('GRTR', ( + rounded_isosceles_trapezoid = ApertureMacro('GRTR', 7, ( ap.Outline('mm', 1, 4, (var(1)/-2, var(2)/-2, var(1)/-2+var(3)/2, var(2)/2, var(1)/2-var(3)/2, var(2)/2, var(1)/2, var(2)/-2, var(1)/-2, var(2)/-2,), - var(6) * -deg_per_rad), + var(7) * -deg_per_rad), ap.VectorLine('mm', 1, var(4)*2, var(1)/-2, var(2)/-2, var(1)/-2+var(3)/2, var(2)/2,), @@ -209,13 +237,13 @@ class GenericMacros: # w must be larger than h # params: width, height, *hole, rotation - obround = ApertureMacro('GNO', ( + obround = ApertureMacro('GNO', 5, ( ap.CenterLine('mm', 1, var(1)-var(2), var(2), 0, 0, var(5) * -deg_per_rad), ap.Circle('mm', 1, var(2), +(var(1)-var(2))/2, 0, var(5) * -deg_per_rad), ap.Circle('mm', 1, var(2), -(var(1)-var(2))/2, 0, var(5) * -deg_per_rad), *_generic_hole(3) )) - polygon = ApertureMacro('GNP', ( + polygon = ApertureMacro('GNP', 4, ( ap.Polygon('mm', 1, var(2), 0, 0, var(1), var(3) * -deg_per_rad), ap.Circle('mm', 0, var(4), 0, 0))) diff --git a/gerbonara/aperture_macros/primitive.py b/gerbonara/aperture_macros/primitive.py index 1372dfa..1738ff7 100644 --- a/gerbonara/aperture_macros/primitive.py +++ b/gerbonara/aperture_macros/primitive.py @@ -7,7 +7,7 @@ import warnings import contextlib import math -from dataclasses import dataclass, fields +from dataclasses import dataclass, fields, replace from .expression import Expression, UnitExpression, ConstantExpression, expr @@ -46,9 +46,20 @@ class Primitive: elif field.type == Expression: object.__setattr__(self, field.name, expr(getattr(self, field.name))) - def to_gerber(self, unit=None): + def to_gerber(self, register_variable=None, settings=None): return f'{self.code},' + ','.join( - getattr(self, field.name).optimized().to_gerber(unit) for field in fields(self) if field.name != 'unit') + getattr(self, field.name).optimized().to_gerber(register_variable, settings.unit) + for field in fields(self) if issubclass(field.type, Expression)) + + def replace_mixed_subexpressions(self, unit): + print('prim rms') + import pprint + out = replace(self, **{ + field.name: getattr(self, field.name).optimized().replace_mixed_subexpressions(unit) + for field in fields(self) if issubclass(field.type, Expression)}) + pprint.pprint(self) + pprint.pprint(out) + return out def __str__(self): attrs = ','.join(str(getattr(self, name)).strip('<>') for name in type(self).__annotations__) @@ -61,6 +72,11 @@ class Primitive: def from_arglist(kls, unit, arglist): return kls(unit, *arglist) + def parameters(self): + for field in fields(self): + if issubclass(field.type, Expression): + yield from getattr(self, field.name).parameters() + class Calculator: def __init__(self, instance, variable_binding={}, unit=None): self.instance = instance @@ -253,9 +269,6 @@ class Outline(Primitive): object.__setattr__(self, 'exposure', expr(self.exposure)) if self.length.calculate() != len(self.coords)//2-1: - print(self.length, self.length.calculate(), len(self.coords)) - import pprint - pprint.pprint(self.coords) raise ValueError('length must exactly equal number of segments, which is the number of points minus one') if self.coords[-2:] != self.coords[:2]: @@ -279,21 +292,33 @@ class Outline(Primitive): def __str__(self): return f'' - def to_gerber(self, unit=None): + def to_gerber(self, register_variable=None, settings=None): # Calculate out rotation since at least gerbv mis-renders Outlines with rotation other than zero. rotation = self.rotation.optimized() coords = self.coords - if isinstance(rotation, ConstantExpression): + if isinstance(rotation, ConstantExpression) and rotation != 0: rotation = math.radians(rotation.value) # This will work even with variables in x and y, we just need to pass in cx and cy as UnitExpressions unit_zero = UnitExpression(expr(0), MM) coords = [ rotate_point(x, y, -rotation, cx=unit_zero, cy=unit_zero) for x, y in self.points ] coords = [ e for point in coords for e in point ] + if not settings.allow_mixed_operators_in_aperture_macros: + coords = [e.replace_mixed_subexpressions(unit=settings.unit) for e in coords] rotation = ConstantExpression(0) - coords = ','.join(coord.optimized().to_gerber(unit) for coord in coords) - return f'{self.code},{self.exposure.optimized().to_gerber()},{len(self.coords)//2-1},{coords},{rotation.to_gerber()}' + coords = ','.join(coord.optimized().to_gerber(register_variable, settings.unit) for coord in coords) + return f'{self.code},{self.exposure.optimized().to_gerber(register_variable)},{len(self.coords)//2-1},{coords},{rotation.to_gerber(register_variable)}' + + def replace_mixed_subexpressions(self, unit): + return replace(Primitive.replace_mixed_subexpressions(self, unit), + coords=[e.replace_mixed_subexpressions(unit) for e in self.coords]) + + def parameters(self): + yield from Primitive.parameters(self) + + for expr in self.coords: + yield from expr.parameters() def to_graphic_primitives(self, offset, rotation, variable_binding={}, unit=None, polarity_dark=True): with self.Calculator(self, variable_binding, unit) as calc: @@ -316,7 +341,7 @@ class Comment: code = 0 comment: str - def to_gerber(self, unit=None): + def to_gerber(self, register_variable=None, settings=None): return f'0 {self.comment}' def dilated(self, offset, unit): diff --git a/gerbonara/apertures.py b/gerbonara/apertures.py index 094001d..09b15d2 100644 --- a/gerbonara/apertures.py +++ b/gerbonara/apertures.py @@ -16,6 +16,7 @@ # limitations under the License. # +import warnings import math from dataclasses import dataclass, replace, field, fields, InitVar, KW_ONLY from functools import lru_cache @@ -448,6 +449,10 @@ class ApertureMacroInstance(Aperture): def _params(self, unit=None): # We ignore "unit" here as we convert the actual macro, not this instantiation. # We do this because here we do not have information about which parameter has which physical units. - return tuple(self.parameters) + parameters = self.parameters + if len(parameters) > self.macro.num_parameters: + warnings.warn('Aperture definition using macro {self.macro.name} has more parameters than the macro uses.') + parameters = parameters[:self.macro.num_parameters] + return tuple(parameters) diff --git a/gerbonara/cam.py b/gerbonara/cam.py index 3c92441..8389425 100644 --- a/gerbonara/cam.py +++ b/gerbonara/cam.py @@ -54,6 +54,13 @@ class FileSettings: zeros : bool = None #: Number format. ``(integer, decimal)`` tuple of number of integer and decimal digits. At most ``(6,7)`` by spec. number_format : tuple = (None, None) + #: At least the aperture macro implementations of gerbv and whatever JLCPCB uses are severely broken and simply + #: ignore parentheses in numeric expressions without throwing an error or a warning, leading to broken rendering. + #: To avoid trouble with severely broken software like this, we split out any non-trivial numeric sub-expressions + #: into separate internal macro variables by default. + #: If you want to export the macros with their original formulaic expressions (which is completely fine by the + #: Gerber standard, btw), set this parameter to ``True`` before exporting. + allow_mixed_operators_in_aperture_macros: bool = False # input validation def __setattr__(self, name, value): diff --git a/gerbonara/layers.py b/gerbonara/layers.py index dc2e00d..4b9d360 100644 --- a/gerbonara/layers.py +++ b/gerbonara/layers.py @@ -881,7 +881,7 @@ class LayerStack: sc_y, tl_y = -1, (bounds[0][1] + bounds[1][1]) if side == 'bottom': - sc_x, sc_y = -1, (bounds[0][0] + bounds[1][0]) + sc_x, tl_x = -1, (bounds[0][0] + bounds[1][0]) else: sc_x, tl_x = 1, 0 layer_group = tag('g', layers, transform=f'translate({tl_x} {tl_y}) scale({sc_x} {sc_y})') diff --git a/gerbonara/rs274x.py b/gerbonara/rs274x.py index 1559740..ad78ceb 100644 --- a/gerbonara/rs274x.py +++ b/gerbonara/rs274x.py @@ -131,7 +131,7 @@ class GerberFile(CamFile): nonlocal cache, settings if isinstance(aperture, apertures.ApertureMacroInstance): macro = aperture.macro - macro_def = macro.to_gerber(unit=settings.unit) + macro_def = macro.to_gerber(settings) if macro_def not in cache: cache[macro_def] = macro @@ -283,7 +283,7 @@ class GerberFile(CamFile): self.dedup_apertures() - am_stmt = lambda macro: f'%AM{macro.name}*\n{macro.to_gerber(unit=settings.unit)}*\n%' + am_stmt = lambda macro: f'%AM{macro.name}*\n{macro.to_gerber(settings)}*\n%' for macro in self.aperture_macros(): yield am_stmt(macro) diff --git a/gerbonara/tests/image_support.py b/gerbonara/tests/image_support.py index 64f59ea..b1b0f59 100644 --- a/gerbonara/tests/image_support.py +++ b/gerbonara/tests/image_support.py @@ -162,7 +162,7 @@ def kicad_fp_export(mod_file, out_svg): fp_name = mod_file.name[:-len('.kicad_mod')] cmd = ['podman', 'run', '--mount', f'type=bind,src={pretty_dir},dst=/{pretty_dir.name}', '--mount', f'type=bind,src={tmpdir},dst=/out', - 'registry.gitlab.com/kicad/kicad-ci/kicad-cli-docker/kicad:nightly', + 'registry.hub.docker.com/kicad/kicad:nightly', 'kicad-cli', 'fp', 'export', 'svg', '--output', '/out', '--footprint', fp_name, f'/{pretty_dir.name}'] subprocess.run(cmd, check=True) #, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) out_file = Path(tmpdir) / f'{fp_name}.svg' diff --git a/gerbonara/tests/test_cli.py b/gerbonara/tests/test_cli.py index d5a0ad4..22f5a72 100644 --- a/gerbonara/tests/test_cli.py +++ b/gerbonara/tests/test_cli.py @@ -43,28 +43,28 @@ def file_mock(): class TestRender: - def invoke(self, *args): + def invoke(self, outfile, *args): runner = CliRunner() res = runner.invoke(cli.render, list(map(str, args))) - print(res.output) + outfile.write_text(str(res.output)) if res.exception: raise res.exception assert res.exit_code == 0 return res.output - def test_basic(self): - assert self.invoke('--version').startswith('Version ') + def test_basic(self, tmpfile): + assert self.invoke(tmpfile('Standard output', '.svg'), '--version').startswith('Version ') @pytest.mark.parametrize('reference', ['example_flash_obround.gbr'], indirect=True) - def test_warnings(self, reference): + def test_warnings(self, reference, tmpfile): with pytest.warns(UserWarning): - self.invoke(reference, '--warnings=once') + self.invoke(tmpfile('Standard output', '.svg'), reference, '--warnings=once') @pytest.mark.parametrize('reference', ['kicad-older'], indirect=True) - def test_side(self, reference): - without = self.invoke(reference, '--warnings=ignore') - top = self.invoke(reference, '--top', '--warnings=ignore') - bottom = self.invoke(reference, '--bottom', '--warnings=ignore') + def test_side(self, reference, tmpfile): + without = self.invoke(tmpfile('Standard output, without args', '.svg'), reference, '--warnings=ignore') + top = self.invoke(tmpfile('Standard output, --top', '.svg'), reference, '--top', '--warnings=ignore') + bottom = self.invoke(tmpfile('Standard output, --bottom', '.svg'), reference, '--bottom', '--warnings=ignore') assert top.strip().startswith('