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 --- README.rst | 79 +++++++------ bruder/__init__.py | 327 ----------------------------------------------------- bruder/__main__.py | 2 - bruder/svg_util.py | 327 ----------------------------------------------------- pyproject.toml | 6 +- readme_flow.png | Bin 0 -> 281551 bytes taep/__init__.py | 327 +++++++++++++++++++++++++++++++++++++++++++++++++++++ taep/__main__.py | 2 + taep/svg_util.py | 327 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 9 files changed, 701 insertions(+), 696 deletions(-) delete mode 100644 bruder/__init__.py delete mode 100644 bruder/__main__.py delete mode 100644 bruder/svg_util.py create mode 100644 readme_flow.png create mode 100644 taep/__init__.py create mode 100644 taep/__main__.py create mode 100644 taep/svg_util.py diff --git a/README.rst b/README.rst index 906ac66..64d479f 100644 --- a/README.rst +++ b/README.rst @@ -1,36 +1,41 @@ -Bruder: Print pictures as collages with label tape printers +taep: Print pictures as collages with label tape printers =========================================================== -``bruder`` is a tool that helps you create collages out of label tape printed with a label printer such as Brother's +.. figure:: readme_flow.png + :width: 800px + + The `taep` workflow from artwork to a physical T-shirt using iron-on label tape. Cat photo by `Jae Park `__. + +``taep`` is a tool that helps you create collages out of label tape printed with a label printer such as Brother's P-touch devices. -``bruder dither`` takes an SVG file with your design and some lines representing the labels' locations, and -outputs a list of dithered PNG files, one for each label, ready for printing. Optionally, ``bruder print`` can also directly +``taep dither`` takes an SVG file with your design and some lines representing the labels' locations, and +outputs a list of dithered PNG files, one for each label, ready for printing. Optionally, ``taep print`` can also directly print these rendered images for you using the ``ptouch-print`` tool. -For creating and tweaking your design, ``bruder`` has several helper utilities built-in. ``bruder template`` will generate a -template with a label tape layout you give it that you can use to base your design off of. ``bruder preview`` will render -a preview that approximates how the printed labels will look when you lay them out. ``bruder assembly`` will render an -assembly diagram with labels indicating each label tape's number in the order output by ``bruder dither`` or `bruder +For creating and tweaking your design, ``taep`` has several helper utilities built-in. ``taep template`` will generate a +template with a label tape layout you give it that you can use to base your design off of. ``taep preview`` will render +a preview that approximates how the printed labels will look when you lay them out. ``taep assembly`` will render an +assembly diagram with labels indicating each label tape's number in the order output by ``taep dither`` or `taep print`. Quick start ----------- -1. Install ``bruder`` from PyPI using `pip install bruder`, and install ``usvg``, ``resvg``, ``didder`` and ``ptouch-print`` as +1. Install ``taep`` from PyPI using `pip install taep`, and install ``usvg``, ``resvg``, ``didder`` and ``ptouch-print`` as shown below under Dependencies_. -2. Run ``bruder template template.svg`` to create a template with pre-generated label tape areas. The default tape width +2. Run ``taep template template.svg`` to create a template with pre-generated label tape areas. The default tape width is 24mm, but you can adjust this as you wish. 3. Create a new SVG document and design your artwork in it. -4. Copy the label tape areas from the template into your artwork SVG and save the resulting SVG. ``bruder`` will later +4. Copy the label tape areas from the template into your artwork SVG and save the resulting SVG. ``taep`` will later identify these areas by their color. You can copy or delete these, change the length of them, and move and rotate them as you wish. Just make sure you don't skew or shear them and that you don't change their width so that they still match the width of the tape that's physically in the printer you're using. -5. Run ``bruder preview artwork_with_label_areas.svg`` on this SVG to render a preview image. By default, ``bruder`` will - open this preview image in your browser. If that doesn't work, run ``bruder preview [input].svg preview_out.svg`` to +5. Run ``taep preview artwork_with_label_areas.svg`` on this SVG to render a preview image. By default, ``taep`` will + open this preview image in your browser. If that doesn't work, run ``taep preview [input].svg preview_out.svg`` to write it to `preview_out.svg` and open that file with an SVG viewer of your choice. Adjust the content of your design as necessary. -6. Run ``bruder print artwork_with_label_areas.svg`` to print your artwork to a brother P-touch label printer connected +6. Run ``taep print artwork_with_label_areas.svg`` to print your artwork to a brother P-touch label printer connected via USB. Dependencies @@ -39,29 +44,29 @@ Dependencies usvg ``usvg`` can be installed using ``cargo install usvg``. - ``usvg`` is used by ``bruder`` to simplify the input SVG file before processing it. + ``usvg`` is used by ``taep`` to simplify the input SVG file before processing it. resvg ``resvg`` can be installed using ``cargo install resvg``. - ``bruder`` uses ``resvg`` to rasterize the SVG file. + ``taep`` uses ``resvg`` to rasterize the SVG file. didder ``didder`` can be installed from source at `https://github.com/makew0rld/didder `__. - ``bruder`` uses ``didder`` to dither the rasterized input, because these thermal label printers can only print two + ``taep`` uses ``didder`` to dither the rasterized input, because these thermal label printers can only print two colors and do not support grayscale. ptouch-print (optional) ``ptouch-print`` can be installed from source at `https://git.familie-radermacher.ch/linux/ptouch-print.git `__. - ``bruder`` uses ``ptouch-print`` when you ask it to print the generated labels using ``bruder print``. You can also use - ``bruder dither`` to just generate a pile of PNGs, and use something else to print them. + ``taep`` uses ``ptouch-print`` when you ask it to print the generated labels using ``taep print``. You can also use + ``taep dither`` to just generate a pile of PNGs, and use something else to print them. Command-Line Interface Usage ---------------------------- -You can override where ``bruder`` looks for ``usvg``, ``resvg``, ``didder`` and ``ptouch-print`` by passing the full path to a +You can override where ``taep`` looks for ``usvg``, ``resvg``, ``didder`` and ``ptouch-print`` by passing the full path to a binary through the environment variables ``USVG``, ``RESVG``, ``DIDDER`` and ``PTOUCH_PRINT``. You can override didder's dithering settings by passing the environment variable `DIDDER_ARGS`. The default value used @@ -69,8 +74,8 @@ when this variable is not set is `edm --serpentine FloydSteinberg`. .. code-block:: shell - bigdata~/p/bruder <3 python -m bruder --help - Usage: python -m bruder [OPTIONS] COMMAND [ARGS]... + bigdata~/p/taep <3 python -m taep --help + Usage: python -m taep [OPTIONS] COMMAND [ARGS]... Options: --help Show this message and exit. @@ -82,13 +87,13 @@ when this variable is not set is `edm --serpentine FloydSteinberg`. print template -``bruder template`` +``taep template`` ~~~~~~~~~~~~~~~~~~~ .. code-block:: shell - bigdata~/p/bruder <3 python -m bruder template --help - Usage: python -m bruder template [OPTIONS] [OUTPUT_SVG] + bigdata~/p/taep <3 python -m taep template --help + Usage: python -m taep template [OPTIONS] [OUTPUT_SVG] Options: --num-rows INTEGER Number of tapes @@ -99,13 +104,13 @@ when this variable is not set is `edm --serpentine FloydSteinberg`. --magic-color TEXT SVG color of tape --help Show this message and exit. -``bruder preview`` +``taep preview`` ~~~~~~~~~~~~~~~~~~ .. code-block:: shell - bigdata~/p/bruder <3 python -m bruder preview --help - Usage: python -m bruder preview [OPTIONS] [INPUT_SVG] [OUTPUT_SVG] + bigdata~/p/taep <3 python -m taep preview --help + Usage: python -m taep preview [OPTIONS] [INPUT_SVG] [OUTPUT_SVG] Options: --magic-color TEXT SVG color of tape @@ -113,13 +118,13 @@ when this variable is not set is `edm --serpentine FloydSteinberg`. --pixel-height INTEGER Printer tape vertical pixel height --help Show this message and exit. -``bruder dither`` +``taep dither`` ~~~~~~~~~~~~~~~~~ .. code-block:: shell - bigdata~/p/bruder <3 python -m bruder dither --help - Usage: python -m bruder dither [OPTIONS] [INPUT_SVG] OUTPUT_DIR + bigdata~/p/taep <3 python -m taep dither --help + Usage: python -m taep dither [OPTIONS] [INPUT_SVG] OUTPUT_DIR Options: --magic-color TEXT SVG color of tape @@ -127,13 +132,13 @@ when this variable is not set is `edm --serpentine FloydSteinberg`. --pixel-height INTEGER Printer tape vertical pixel height --help Show this message and exit. -``bruder print`` +``taep print`` ~~~~~~~~~~~~~~~~ .. code-block:: shell - bigdata~/p/bruder <3 python -m bruder print --help - Usage: python -m bruder print [OPTIONS] [INPUT_SVG] + bigdata~/p/taep <3 python -m taep print --help + Usage: python -m taep print [OPTIONS] [INPUT_SVG] Options: --magic-color TEXT SVG color of tape @@ -146,13 +151,13 @@ when this variable is not set is `edm --serpentine FloydSteinberg`. included. --help Show this message and exit. -``bruder assembly`` +``taep assembly`` ~~~~~~~~~~~~~~~~~~~ .. code-block:: shell - bigdata~/p/bruder <3 python -m bruder assembly --help - Usage: python -m bruder assembly [OPTIONS] [INPUT_SVG] [OUTPUT_SVG] + bigdata~/p/taep <3 python -m taep assembly --help + Usage: python -m taep assembly [OPTIONS] [INPUT_SVG] [OUTPUT_SVG] Options: --magic-color TEXT SVG color of tape 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:] - - diff --git a/pyproject.toml b/pyproject.toml index f71352a..8faf7ec 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,10 +3,10 @@ requires = ["setuptools"] build-backend = "setuptools.build_meta" [tool.setuptools] -py-modules = ['bruder'] +py-modules = ['taep'] [project] -name = "bruder" +name = "taep" version = "v1.0.0-rc1" description = "Print pictures as collages using label tape printers" readme = "README.rst" @@ -36,5 +36,5 @@ classifiers = [ ] [project.urls] -homepage = "https://github.com/jaseg/bruder" +homepage = "https://github.com/jaseg/taep" diff --git a/readme_flow.png b/readme_flow.png new file mode 100644 index 0000000..31f2cfa Binary files /dev/null and b/readme_flow.png differ diff --git a/taep/__init__.py b/taep/__init__.py new file mode 100644 index 0000000..985705f --- /dev/null +++ b/taep/__init__.py @@ -0,0 +1,327 @@ +#!/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/taep/__main__.py b/taep/__main__.py new file mode 100644 index 0000000..1a346dd --- /dev/null +++ b/taep/__main__.py @@ -0,0 +1,2 @@ +import taep +taep.cli() diff --git a/taep/svg_util.py b/taep/svg_util.py new file mode 100644 index 0000000..009e9a5 --- /dev/null +++ b/taep/svg_util.py @@ -0,0 +1,327 @@ +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