From 2af2479196370f5a1ab3d7f1c275d80047e5c258 Mon Sep 17 00:00:00 2001 From: jaseg Date: Sun, 21 Apr 2024 19:17:51 +0200 Subject: Repo re-org, update README --- bruder/__init__.py | 327 ----------------------------------------------------- bruder/__main__.py | 2 - bruder/svg_util.py | 327 ----------------------------------------------------- 3 files changed, 656 deletions(-) delete mode 100644 bruder/__init__.py delete mode 100644 bruder/__main__.py delete mode 100644 bruder/svg_util.py (limited to 'bruder') diff --git a/bruder/__init__.py b/bruder/__init__.py deleted file mode 100644 index 985705f..0000000 --- a/bruder/__init__.py +++ /dev/null @@ -1,327 +0,0 @@ -#!/usr/bin/env python3 - -import tempfile -import webbrowser -import re -import base64 -import copy -import subprocess -import shlex -import os -import sys -import math -from pathlib import Path - -import click -from bs4 import BeautifulSoup - -from .svg_util import * - - -__version__ = "v1.0.0-rc1" - - -USVG_DPI = 96.0 - -def run_cargo_command(binary, *args, **kwargs): - # By default, try a number of options: - candidates = [ - # somewhere in $PATH - binary, - # wasi-wrapper in $PATH - f'wasi-{binary}', - # in user-local cargo installation - Path.home() / '.cargo' / 'bin' / binary, - # wasi-wrapper in user-local pip installation - Path.home() / '.local' / 'bin' / f'wasi-{binary}', - # next to our current python interpreter (e.g. in virtualenv) - str(Path(sys.executable).parent / f'wasi-{binary}') - ] - - return run_command(binary, *args, candidates=candidates, **kwargs) - - -def run_command(binary, *args, candidates=[], **kwargs): - cmd_args = [] - for key, value in kwargs.items(): - if value is not None: - if value is False: - continue - - if len(key) > 1: - cmd_args.append(f'--{key.replace("_", "-")}') - else: - cmd_args.append(f'-{key}') - - if value is not True: - cmd_args.append(str(value)) - cmd_args.extend(map(str, args)) - - # By default, try a number of options: - if not candidates: - candidates = [binary] - - # if envvar is set, try that first. - if (env_var := os.environ.get(Path(binary).name.replace('-', '_').upper())): - candidates = [str(Path(env_var).expanduser()), *candidates] - - for cand in candidates: - try: - res = subprocess.run([cand, *cmd_args], check=True) - break - except FileNotFoundError: - continue - else: - raise SystemError(f'{binary} executable not found') - - -def simplify_and_open_svg(data): - with tempfile.NamedTemporaryFile('w', suffix='.svg') as tmp_in_svg,\ - tempfile.NamedTemporaryFile('r', suffix='.svg') as tmp_out_svg: - tmp_in_svg.write(data) - tmp_in_svg.flush() - - try: - run_cargo_command('usvg', *shlex.split(os.environ.get('USVG_OPTIONS', '')), tmp_in_svg.name, tmp_out_svg.name) - except SystemError: - raise click.ClickException('Cannot find usvg. Please install usvg using cargo, or pass the full path to the usvg binary in the USVG environment variable.') - except subprocess.CalledProcessError as e: - raise click.ClickException(f'usvg exited with return code {e.returncode}.') - - return BeautifulSoup(tmp_out_svg.read(), 'xml') - - -def print_tape(png_file): - try: - run_command('ptouch-print', '--image', png_file) - except SystemError: - raise click.ClickException('Cannot find ptouch-print. Please install ptouch-print from the upstream repo at https://git.familie-radermacher.ch/linux/ptouch-print.git . You can pass the full path to the ptouch-print binary in the PTOUCH_PRINT environment variable if it\'s not in $PATH.') - except subprocess.CalledProcessError as e: - raise click.ClickException(f'ptouch-print exited with return code {e.returncode}.') - - -def calc_scale(soup): - svg = soup.find('svg') - vb_x, vb_y, vb_w, vb_h = map(float, svg['viewBox'].split()) - doc_w, doc_h = float(svg['width']), float(svg['height']) - doc_w_mm = doc_w / USVG_DPI * 25.4 - doc_h_mm = doc_h / USVG_DPI * 25.4 - mm_per_px_x = doc_w_mm / vb_w - mm_per_px_y = doc_h_mm / vb_h - return mm_per_px_x, mm_per_px_y - - -def do_dither(soup, magic_color, dpi, pixel_height): - mm_per_px_x, mm_per_px_y = calc_scale(soup) - - for i, path in enumerate(list(soup.find_all('path'))): - if path.get('stroke').lower() != magic_color: - continue - path_id = path.get('id', '') - - commands = list(parse_path_d(path.get('d', ''))) - if len(commands) != 2: - print('Path', path_id, 'has magic color, but has more than two nodes. Ignoring.', file=sys.stderr) - continue - if commands[1][0] != 'L': - print('Path', path_id, 'has magic color, but has a curve. Ignoring.', file=sys.stderr) - continue - if commands[0][0] != 'M': - print('Path', path_id, 'has magic color, but is malformed (does not start with M command). Ignoring.', file=sys.stderr) - continue - (_c1, (x1, y1)), (_c2, (x2, y2)) = commands - - mat = Transform.parse_svg(path.get('transform', '')) - for parent in path.parents: - xf = Transform.parse_svg(parent.get('transform', '')) - mat = xf * mat # make sure we apply the parent transform from the left, i.e. after the child transform - x1, y1 = mat.transform_point(x1, y1) - x2, y2 = mat.transform_point(x2, y2) - path_len = math.dist((x1, y1), (x2, y2)) - path_len_mm = math.dist((x1*mm_per_px_x, y1*mm_per_px_y), (x2*mm_per_px_x, y2*mm_per_px_y)) - - if math.isclose(path_len, 0, abs_tol=1e-3): - print('Path', path_id, 'has magic color, but has (almost) zero length. Ignoring.', file=sys.stderr) - continue - - if not (stroke_w := path.get('stroke-width')): - print('Path', path_id, 'has magic color, but has no defined stroke width. Ignoring.', file=sys.stderr) - continue - stroke_w = float(re.match('[-0-9.]+', stroke_w).group(0)) - - path_angle = math.atan2((y2-y1), (x2-x1)) - dx, dy = (x2-x1)/path_len, (y2-y1)/path_len - - sx1, sy1 = mat.transform_point(x1-dy*stroke_w/2, y1+dx*stroke_w/2) - sx2, sy2 = mat.transform_point(x1+dy*stroke_w/2, y1-dx*stroke_w/2) - stroke_w = round(math.dist((sx1, sy1), (sx2, sy2)), 3) - stroke_w_mm = round(math.dist((sx1*mm_per_px_x, sy1*mm_per_px_y), (sx2*mm_per_px_x, sy2*mm_per_px_y)), 3) - - out_soup = copy.copy(soup) - - print(f'Identified tape from path "{path_id}", length {path_len_mm:2f} mm, angle {math.degrees(path_angle):.1f} deg with physical stroke width {stroke_w_mm:.2f} mm from ({x1:.2f}, {y1:.2f}) to ({x2:.2f}, {y2:.2f})') - #out_soup.find('svg').append(out_soup.new_tag('path', fill='none', stroke='blue', stroke_width=f'24px', - # d=f'M {x1} {y1} L {x2} {y2}')) - xf = Transform.translate(0, stroke_w/2) * Transform.rotate(-path_angle) * Transform.translate(-x1, -y1) - g = out_soup.new_tag('g', id='transform-group', transform=xf.as_svg()) - k = list(out_soup.find('svg').contents) - for c in k: - g.append(c.extract()) - out_soup.find('svg').append(g) - out_soup.find('path', id=path['id']).parent.decompose() - out_soup.find('svg')['viewBox'] = f'0 0 {path_len} {stroke_w}' - out_soup.find('svg')['width'] = f'{path_len_mm}mm' - out_soup.find('svg')['height'] = f'{stroke_w_mm}mm' - - with tempfile.NamedTemporaryFile('w', suffix='.svg') as tmp_svg,\ - tempfile.NamedTemporaryFile('rb', suffix='.png') as tmp_png,\ - tempfile.NamedTemporaryFile('rb', suffix='.png') as tmp_dither: - tmp_svg.write(out_soup.prettify()) - tmp_svg.flush() - run_cargo_command('resvg', tmp_svg.name, tmp_png.name, width=round(Inch(path_len, 'mm')*dpi), height=pixel_height) - - args = shlex.split(os.environ.get('DIDDER_ARGS', 'edm --serpentine FloydSteinberg')) - run_command('didder', *args, palette='black white', i=tmp_png.name, o=tmp_dither.name) - yield (x1, y1, path_angle, stroke_w, path_len), tmp_dither.read() - - -def make_preview(input_svg, out_file, *dither_args, assembly_labels=False, **dither_kwargs): - imgs = [] - labels = [] - soup = simplify_and_open_svg(input_svg) - - for tape_num, ((x1, y1, path_angle, stroke_w, path_len), img) in enumerate(do_dither(soup, *dither_args, **dither_kwargs), start=1): - xf = f'translate({x1} {y1}) rotate({math.degrees(path_angle)}) translate(0 {-stroke_w/2})' - imgs.append(Tag('image', width=path_len, height=stroke_w, preserveAspectRatio='none', - id=f'preview_image_{tape_num}', - x=0, y=0, - transform=xf, - xlink__href=f'data:image/png;base64,{base64.b64encode(img).decode()}')) - labels.append(Tag('path', fill='none', stroke_width='0.2px', stroke='red', transform=xf, - d=f'M 0 0 h {path_len} v {stroke_w} h {-path_len} Z')) - labels.append(Tag('text', fill='red', stroke='none', font_size=f'{stroke_w*0.8}px', transform=xf, - x='2px', y=f'{stroke_w*0.9}px', children=[f'{tape_num}'])) - - layer = Tag('g', inkscape__layer='Preview', inkscape__groupmode='layer', id='layer_preview', children=[ - Tag('g', id='preview_images', children=imgs), - ]) - - if assembly_labels: - layer.children.append(Tag('g', id='assembly_instructions', children=labels)) - - vbx, vby, vbw, vbh = map(float, soup.find('svg')['viewBox'].split()) - bounds = (vbx, vby), (vbx+vbw, vby+vbh) - svg = setup_svg([layer], bounds, inkscape=True) - - if out_file is not None: - out_file.write(str(svg)) - else: - with tempfile.NamedTemporaryFile(suffix='.svg', mode='w', delete=False) as f: - f.write(str(svg)) - f.flush() - webbrowser.open_new_tab(f'file://{f.name}') - - -@click.group() -def cli(): - pass - -@cli.command() -@click.option('--num-rows', type=int, default=5, help='Number of tapes') -@click.option('--tape-width', type=float, default=24, help='Width of tape') -@click.option('--tape-border', type=float, default=3, help='Width of empty border at the edges of the tape in mm') -@click.option('--tape-spacing', type=float, default=2, help='Space between tapes') -@click.option('--tape-length', type=float, default=250, help='Length of tape segments') -@click.option('--magic-color', type=str, default='#cc0301', help='SVG color of tape') -@click.argument('output_svg', type=click.File(mode='w'), default='-') -def template(num_rows, tape_width, tape_border, tape_spacing, tape_length, magic_color, output_svg): - pitch = tape_width + tape_spacing - tags = [Tag('g', inkscape__layer='Layer 1', inkscape__groupmode='layer', id='layer1', children=[ - Tag('g', id='g1', children=[ - Tag('g', id=f'tape{i}', children=[ - Tag('path', id=f'tape{i}_outline', fill='none', stroke='black', opacity='0.3', stroke_width=f'{tape_width}px', - d=f'M 0 {tape_width/2 + i*pitch} {tape_length} {tape_width/2 + i*pitch}'), - Tag('path', id=f'tape{i}_printable_area', fill='none', stroke=magic_color, stroke_width=f'{tape_width-2*tape_border}px', - d=f'M 0 {tape_width/2 + i*pitch} {tape_length} {tape_width/2 + i*pitch}'), - ]) - for i in range(num_rows) - ]) - ])] - - bounds = (0, 0), (tape_length, num_rows*tape_width + (num_rows-1)*tape_spacing) - svg = setup_svg(tags, bounds, margin=tape_width, inkscape=True) - output_svg.write(str(svg)) - - -@cli.command('print') -@click.option('--magic-color', type=str, default='#cc0301', help='SVG color of tape') -@click.option('--dpi', type=float, default=180, help='Printer bitmap resolution in DPI') -@click.option('--pixel-height', type=int, default=127, help='Printer tape vertical pixel height') -@click.option('--confirm/--no-confirm', default=True, help='Ask for confirmation before printing each tape') -@click.option('--tape', type=str, default='-', help='The index numbers of which tapes to print. Comma-separate list, each entry is either a single number or a "3-5" style range where both ends are included.') -@click.argument('input_svg', type=click.File(mode='r'), default='-') -def cli_print(input_svg, tape, magic_color, dpi, pixel_height, confirm): - with tempfile.TemporaryDirectory() as tmpdir: - out = {} - - soup = simplify_and_open_svg(input_svg.read()) - for i, (_tape_pos, img) in enumerate(do_dither(soup, magic_color=magic_color, dpi=dpi, pixel_height=pixel_height), start=1): - f = Path(tmpdir) / f'dither_tape_{i}.png' - f.write_bytes(img) - out[i] = f - - selected = set() - for entry in tape.split(','): - start, sep, stop = entry.partition('-') - if not sep: - selected.add(int(start)) - else: - start = int(start) if start else min(out) - stop = int(stop) if stop else max(out) - selected |= set(range(start, stop+1)) - - for tape in sorted(selected): - if confirm: - if not click.confirm(f'Do you want to continue and print tape {tape}?'): - break - print_tape(out[tape]) - - -@cli.command() -@click.option('--magic-color', type=str, default='#cc0301', help='SVG color of tape') -@click.option('--dpi', type=float, default=180, help='Printer bitmap resolution in DPI') -@click.option('--pixel-height', type=int, default=127, help='Printer tape vertical pixel height') -@click.argument('input_svg', type=click.File(mode='r'), default='-') -@click.argument('output_svg', type=click.File(mode='w'), required=False) -def preview(input_svg, output_svg, magic_color, dpi, pixel_height): - make_preview(input_svg.read(), output_svg, magic_color=magic_color, dpi=dpi, pixel_height=pixel_height, assembly_labels=False) - - -@cli.command() -@click.option('--magic-color', type=str, default='#cc0301', help='SVG color of tape') -@click.option('--dpi', type=float, default=180, help='Printer bitmap resolution in DPI') -@click.option('--pixel-height', type=int, default=127, help='Printer tape vertical pixel height') -@click.argument('input_svg', type=click.File(mode='r'), default='-') -@click.argument('output_svg', type=click.File(mode='w'), required=False) -def assembly(input_svg, output_svg, magic_color, dpi, pixel_height): - make_preview(input_svg.read(), output_svg, magic_color=magic_color, dpi=dpi, pixel_height=pixel_height, assembly_labels=True) - - -@cli.command() -@click.option('--magic-color', type=str, default='#cc0301', help='SVG color of tape') -@click.option('--dpi', type=float, default=180, help='Printer bitmap resolution in DPI') -@click.option('--pixel-height', type=int, default=127, help='Printer tape vertical pixel height') -@click.argument('input_svg', type=click.File(mode='r'), default='-') -@click.argument('output_dir', type=click.Path(file_okay=False, dir_okay=True, path_type=Path)) -def dither(input_svg, output_dir, magic_color, dpi, pixel_height): - output_dir.mkdir(exist_ok=True) - soup = simplify_and_open_svg(input_svg.read()) - for i, (_tape_pos, img) in enumerate(do_dither(soup, magic_color=magic_color, dpi=dpi, pixel_height=pixel_height), start=1): - outfile = output_dir / f'dither_tape_{i}.png' - outfile.write_bytes(img) - print(f'Wrote {outfile}') - - -if __name__ == '__main__': - cli() - diff --git a/bruder/__main__.py b/bruder/__main__.py deleted file mode 100644 index aa0ea12..0000000 --- a/bruder/__main__.py +++ /dev/null @@ -1,2 +0,0 @@ -import bruder -bruder.cli() diff --git a/bruder/svg_util.py b/bruder/svg_util.py deleted file mode 100644 index 009e9a5..0000000 --- a/bruder/svg_util.py +++ /dev/null @@ -1,327 +0,0 @@ -import math -import re -import textwrap -from dataclasses import dataclass - - -@dataclass(frozen=True, slots=True) -class LengthUnit: - """ Convenience length unit class. Used in :py:class:`.GraphicObject` and :py:class:`.Aperture` to store length - information. Provides a number of useful unit conversion functions. - - Singleton, use only global instances ``utils.MM`` and ``utils.Inch``. - """ - - name: str - shorthand: str - this_in_mm: float - - def convert_from(self, unit, value): - """ Convert ``value`` from ``unit`` into this unit. - - :param unit: ``MM``, ``Inch`` or one of the strings ``"mm"`` or ``"inch"`` - :param float value: - :rtype: float - """ - - if isinstance(unit, str): - unit = units[unit] - - if unit == self or unit is None or value is None: - return value - - return value * unit.this_in_mm / self.this_in_mm - - def convert_to(self, unit, value): - """ :py:meth:`.LengthUnit.convert_from` but in reverse. """ - - if isinstance(unit, str): - unit = to_unit(unit) - - if unit is None: - return value - - return unit.convert_from(self, value) - - def convert_bounds_from(self, unit, value): - """ :py:meth:`.LengthUnit.convert_from` but for ((min_x, min_y), (max_x, max_y)) bounding box tuples. """ - - if value is None: - return None - - (min_x, min_y), (max_x, max_y) = value - min_x = self.convert_from(unit, min_x) - min_y = self.convert_from(unit, min_y) - max_x = self.convert_from(unit, max_x) - max_y = self.convert_from(unit, max_y) - return (min_x, min_y), (max_x, max_y) - - def convert_bounds_to(self, unit, value): - """ :py:meth:`.LengthUnit.convert_to` but for ((min_x, min_y), (max_x, max_y)) bounding box tuples. """ - - if value is None: - return None - - (min_x, min_y), (max_x, max_y) = value - min_x = self.convert_to(unit, min_x) - min_y = self.convert_to(unit, min_y) - max_x = self.convert_to(unit, max_x) - max_y = self.convert_to(unit, max_y) - return (min_x, min_y), (max_x, max_y) - - def format(self, value): - """ Return a human-readdable string representing value in this unit. - - :param float value: - :returns: something like "3mm" - :rtype: str - """ - - return f'{value:.3f}{self.shorthand}' if value is not None else '' - - def __call__(self, value, unit): - """ Convenience alias for :py:meth:`.LengthUnit.convert_from` """ - return self.convert_from(unit, value) - - def __eq__(self, other): - if isinstance(other, str): - return other.lower() in (self.name, self.shorthand) - else: - return id(self) == id(other) - - # This class is a singleton, we don't want copies around - def __copy__(self): - return self - - def __deepcopy__(self, memo): - return self - - def __str__(self): - return self.shorthand - - def __repr__(self): - return f'' - - -MILLIMETERS_PER_INCH = 25.4 -Inch = LengthUnit('inch', 'in', MILLIMETERS_PER_INCH) -MM = LengthUnit('millimeter', 'mm', 1) -units = {'inch': Inch, 'mm': MM, None: None} - - -class Tag: - """ Helper class to ease creation of SVG. All API functions that create SVG allow you to substitute this with your - own implementation by passing a ``tag`` parameter. """ - - def __init__(self, name, children=None, root=False, **attrs): - if (fill := attrs.get('fill')) and isinstance(fill, tuple): - attrs['fill'], attrs['fill-opacity'] = fill - if (stroke := attrs.get('stroke')) and isinstance(stroke, tuple): - attrs['stroke'], attrs['stroke-opacity'] = stroke - self.name, self.attrs = name, attrs - self.children = children or [] - self.root = root - - def __str__(self): - prefix = '\n' if self.root else '' - opening = ' '.join([self.name] + [f'{key.replace("__", ":").replace("_", "-")}="{value}"' for key, value in self.attrs.items()]) - if self.children: - children = '\n'.join(textwrap.indent(str(c), ' ') for c in self.children) - return f'{prefix}<{opening}>\n{children}\n' - else: - return f'{prefix}<{opening}/>' - - -def svg_rotation(angle_rad, cx=0, cy=0): - if math.isclose(angle_rad, 0.0, abs_tol=1e-3): - return {} - else: - return {'transform': f'rotate({float(math.degrees(angle_rad)):.4} {float(cx):.6} {float(cy):.6})'} - -def setup_svg(tags, bounds, margin=0, arg_unit=MM, svg_unit=MM, pagecolor='white', tag=Tag, inkscape=False): - (min_x, min_y), (max_x, max_y) = bounds - - if margin: - margin = svg_unit(margin, arg_unit) - min_x -= margin - min_y -= margin - max_x += margin - max_y += margin - - w, h = max_x - min_x, max_y - min_y - w = 1.0 if math.isclose(w, 0.0) else w - h = 1.0 if math.isclose(h, 0.0) else h - - if inkscape: - tags.insert(0, tag('sodipodi:namedview', [], id='namedview1', pagecolor=pagecolor, - inkscape__document_units=svg_unit.shorthand)) - namespaces = dict( - xmlns="http://www.w3.org/2000/svg", - xmlns__xlink="http://www.w3.org/1999/xlink", - xmlns__sodipodi='http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd', - xmlns__inkscape='http://www.inkscape.org/namespaces/inkscape') - - else: - namespaces = dict( - xmlns="http://www.w3.org/2000/svg", - xmlns__xlink="http://www.w3.org/1999/xlink") - - svg_unit = 'in' if svg_unit == 'inch' else 'mm' - # TODO export apertures as where reasonable. - return tag('svg', tags, - width=f'{w}{svg_unit}', height=f'{h}{svg_unit}', - viewBox=f'{min_x} {min_y} {w} {h}', - style=f'background-color:{pagecolor}', - **namespaces, - root=True) - - -class Transform: - xform_re = r'((matrix|translate|scale|rotate|skewX|skewY)\(([-0-9. ]+)\))|(.+)' - - def __init__(self, a=1, b=0, c=0, d=1, e=0, f=0): - # Reference: https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/transform - self.mat = (a, b, c, d, e, f) - - def __mul__(self, other): - a1, b1, c1, d1, e1, f1 = self.mat - a2, b2, c2, d2, e2, f2 = other.mat - - a = a1*a2 + c1*b2 - b = d1*b2 + b1*a2 - c = c1*d2 + a1*c2 - d = d1*d2 + b1*c2 - e = e1 + c1*f2 + a1*e2 - f = f1 + d1*f2 + b1*e2 - - return Transform(a, b, c, d, e, f) - - def __str__(self): - a, b, c, d, e, f = self.mat - return f'Transform({a=:.3f} {b=:.3f} {c=:.3f} {d=:.3f} {e=:.3f} {f=:.3f})' - - def transform_point(self, x, y): - a, b, c, d, e, f = self.mat - x_new = a*x + c*y + e - y_new = b*x + d*y + f - return x_new, y_new - - @classmethod - def translate(kls, x, y): - return kls(1, 0, 0, 1, x, y) - - @classmethod - def scale(kls, x, y): - return kls(x, 0, 0, y, 0, 0) - - @classmethod - def rotate(kls, a, x=0, y=0): - s, c = math.sin(a), math.cos(a) - mat = kls(c, s, -s, c, 0, 0) - if not math.isclose(x, 0) or not math.isclose(y, 0): - mat = kls.translate(x, y) * (mat * kls.translate(-x, -y)) - return mat - - @classmethod - def skew_x(kls, a): - return kls(1, 0, math.tan(a), 1, 0, 0) - - @classmethod - def skew_y(kls, a): - return kls(1, math.tan(a), 0, 1, 0, 0) - - @classmethod - def _parse_single_svg(kls, xform_string): - _transform, name, nums, _garbage = re.match(kls.xform_re, xform_string).groups() - nums = [float(x) for x in nums.strip().split()] - match (name, *nums): - case ('matrix', a, b, c, d, e, f): - return kls(a, b, c, d, e, f) - case ('translate', x): - return kls.translate(x, 0) - case ('translate', x, y): - return kls.translate(x, y) - case ('scale', s): - return kls.scale(s, s) - case ('scale', x, y): - return kls.scale(x, y) - case ('rotate', a): - return kls.rotate(math.radians(a)) - case ('rotate', a, x, y): - return kls.rotate(math.radians(a), x, y) - case ('skewX', a): - return kls.skew_x(math.radians(a)) - case ('skewY', a): - return kls.skew_y(math.radians(a)) - - @classmethod - def parse_svg(kls, xform_string): - mat = kls() - for xf in re.finditer(kls.xform_re, xform_string): - component, command, params, garbage = xf.groups() - if garbage: - raise ValueError(f'Unknown SVG transform {garbage!r}') - mat *= kls._parse_single_svg(xf.group(0)) - return mat - - def as_svg(self): - a, b, c, d, e, f = self.mat - return f'matrix({a} {b} {c} {d} {e} {f})' - - -def parse_path_d(d): - # Reference: https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/d#path_commands - cur_x, cur_y = None, None - start_x, start_y = None, None - for m in re.finditer(r'([MmLlHhVvCcSsQqTtAaZz])\s*((-?[0-9.]+)(\s*[\s,]\s*-?[0-9.]+)*)', d): - command = m.group(1) - is_relative, command = command.islower(), command.upper() - params = [float(x or 0) for x in re.split(r'\s*[\s,]\s*', m.group(2).strip())] - - def r(x, y, reset=True): - if is_relative: - x, y = x+cur_x, y+cur_y - if reset: - cur_x, cur_y = x, y - return x, y - - if command == 'Z': - if params: - raise ValueError('Z (close path) command followed by numeric parameters') - if not math.isclose(cur_x, start_x) or not math.isclose(cur_y, start_y): - yield 'L', (start_x, start_y) - - else: - while params: - match (command, *params): - case ('M', x, y, *_extra): - yield 'M', r(x, y) - start_x, start_y = cur_x, cur_y - command = 'L' - params = params[2:] - case ('L', x, y, *_extra): - yield 'L', r(x, y) - params = params[2:] - case ('H', x, *_extra): - yield 'L', r(x, 0 if is_relative else cur_y) - params = params[1:] - case ('V', y, *_extra): - yield 'L', r(0 if is_relative else cur_x, y) - params = params[1:] - case ('C', x1, y1, x2, y2, x, y, *_extra): - yield 'C', r(x1, y1, False), r(x2, y2, False), r(x, y) - params = params[6:] - case ('S', dx2, dy2, x, y, *_extra): - yield 'S', r(dx2, dy2, False), r(x, y) - params = params[4:] - case ('Q', x1, y1, x, y, *_extra): - yield 'Q', r(x1, y1, False), r(x, y) - params = params[4:] - case ('T', x, y, *_extra): - yield 'T', r(x, y) - params = params[2:] - case ('A', rx, ry, a, l, s, x, y, *_extra): - yield 'A', (rx, ry), a, l, s, r(x, y) - params = params[7:] - - -- cgit