Split CLI into sub-commands and add print command

This commit is contained in:
jaseg 2024-04-20 13:50:08 +02:00
parent 58bdba8d03
commit 065db66e95

212
bruder.py
View file

@ -2,6 +2,7 @@
import tempfile
import shutil
import webbrowser
import re
import base64
import copy
@ -57,7 +58,7 @@ def run_command(binary, *args, candidates=[], **kwargs):
candidates = [binary]
# if envvar is set, try that first.
if (env_var := os.environ.get(binary.upper())):
if (env_var := os.environ.get(Path(binary).name.replace('-', '_').upper())):
candidates = [str(Path(env_var).expanduser()), *candidates]
for cand in candidates:
@ -70,57 +71,32 @@ def run_command(binary, *args, candidates=[], **kwargs):
raise SystemError(f'{binary} executable not found')
@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='#cc0000', 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()
@click.option('--magic-color', type=str, default='#cc0000', 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='-')
def dither(input_svg, magic_color, dpi, pixel_height):
tmpdir = Path('/tmp/foo') # FIXME debug
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(input_svg.read())
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 ClickException('Cannot find usvg. Please install usvg using cargo, or pass the full path to the usvg binary in the USVG environment variable.')
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}.')
soup = BeautifulSoup(tmp_out_svg.read(), 'xml')
return BeautifulSoup(tmp_out_svg.read(), 'xml')
preview_images = []
tape_num = 1
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 do_dither(soup, magic_color, dpi, pixel_height):
for i, path in enumerate(list(soup.find_all('path'))):
if path.get('stroke').lower() != magic_color:
continue
@ -164,7 +140,7 @@ def dither(input_svg, magic_color, dpi, pixel_height):
out_soup = copy.copy(soup)
print(f'found path {path_id} of length {path_len:2f} and angle {math.degrees(path_angle):.1f} deg with physical stroke width {stroke_w:.2f} from ({x1:.2f}, {y1:.2f}) to ({x2:.2f}, {y2:.2f})', file=sys.stderr)
#print(f'found path {path_id} of length {path_len:2f} and angle {math.degrees(path_angle):.1f} deg with physical stroke width {stroke_w:.2f} from ({x1:.2f}, {y1:.2f}) to ({x2:.2f}, {y2:.2f})', file=sys.stderr)
#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)
@ -184,28 +160,148 @@ def dither(input_svg, magic_color, dpi, pixel_height):
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)
shutil.copy(tmp_png.name, f'/tmp/debug_{i}.png')
run_command('didder', 'edm', '--serpentine', 'FloydSteinberg', palette='black white', i=tmp_png.name, o=tmp_dither.name)
shutil.copy(tmp_dither.name, f'/tmp/dither_{tape_num}.png')
preview_images.append(Tag('image', width=path_len, height=stroke_w, preserveAspectRatio='none',
id=f'preview_image_{tape_num}',
x=0, y=0,
transform=f'translate({x1} {y1}) rotate({math.degrees(path_angle)}) translate(0 {-stroke_w/2})',
xlink__href=f'data:image/png;base64,{base64.b64encode(tmp_dither.read()).decode()}'))
yield (x1, y1, path_angle, stroke_w, path_len), tmp_dither.read()
print('wrote', f'/tmp/dither_{tape_num}.png')
tape_num += 1
tags = [Tag('g', inkscape__layer='Layer 1', inkscape__groupmode='layer', id='layer1', children=[
Tag('g', id='g1', children=preview_images)
])]
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(tags, bounds, inkscape=True)
Path('/tmp/preview.svg').write_text(str(svg))
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='#cc0000', 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='#cc0000', 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='#cc0000', 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='#cc0000', 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='#cc0000', 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()