hatchsvg 2.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- hatchsvg/__init__.py +15 -0
- hatchsvg/__main__.py +6 -0
- hatchsvg/cli.py +411 -0
- hatchsvg/core.py +1662 -0
- hatchsvg/data/palettes/crayola_10ct_fine_line_classic.json +17 -0
- hatchsvg/data/palettes/jot_20ct_washable_fineline.json +27 -0
- hatchsvg/presets.py +114 -0
- hatchsvg-2.0.0.dist-info/METADATA +310 -0
- hatchsvg-2.0.0.dist-info/RECORD +12 -0
- hatchsvg-2.0.0.dist-info/WHEEL +4 -0
- hatchsvg-2.0.0.dist-info/entry_points.txt +2 -0
- hatchsvg-2.0.0.dist-info/licenses/LICENSE +21 -0
hatchsvg/__init__.py
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""hatchsvg — Convert images to hatched SVG files for Cricut pen plotters.
|
|
2
|
+
|
|
3
|
+
A pure-Python hatch generator for pen plotters. Reads raster images
|
|
4
|
+
(PNG, JPG, WebP, BMP, GIF, TIFF), quantizes them to a small palette,
|
|
5
|
+
maps each color to the nearest real-world marker (Crayola, Jot, or
|
|
6
|
+
custom JSON palette), and traces each color layer into parallel
|
|
7
|
+
hatched SVG paths. Output is optimized for Cricut Design Space,
|
|
8
|
+
Axidraw, EggBot, and any pen plotter that reads SVG.
|
|
9
|
+
|
|
10
|
+
Originally released as ``png2svg`` (v1.0.0 - v1.2.0). Renamed to
|
|
11
|
+
``hatchsvg`` in v2.0.0 to match the GitHub repository name and to
|
|
12
|
+
better describe the algorithm (hatching, not tracing).
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
__version__ = "2.0.0"
|
hatchsvg/__main__.py
ADDED
hatchsvg/cli.py
ADDED
|
@@ -0,0 +1,411 @@
|
|
|
1
|
+
"""Command-line interface for hatchsvg."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import re
|
|
5
|
+
import sys
|
|
6
|
+
import webbrowser
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from hatchsvg import __version__
|
|
10
|
+
from hatchsvg.core import (
|
|
11
|
+
HAS_RICH,
|
|
12
|
+
_display_stats_table,
|
|
13
|
+
get_run_configuration,
|
|
14
|
+
process_image_to_hatched_svg,
|
|
15
|
+
render_single_layer_svg,
|
|
16
|
+
save_session,
|
|
17
|
+
)
|
|
18
|
+
from hatchsvg.presets import PRESETS, list_presets
|
|
19
|
+
|
|
20
|
+
_ERROR_HINTS = {
|
|
21
|
+
"No layers produced.": (
|
|
22
|
+
"No layers were produced. Try:\n"
|
|
23
|
+
" - Adding --no-skip-background (to include the background color)\n"
|
|
24
|
+
" - Lowering --min-pixels (e.g. --min-pixels 50)\n"
|
|
25
|
+
" - Checking your input image has visible content"
|
|
26
|
+
),
|
|
27
|
+
"No visible pixels.": (
|
|
28
|
+
"No visible pixels found in the image.\nCheck that your image is not fully transparent or all-black."
|
|
29
|
+
),
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
# Input formats Pillow can open (the actual decode happens in core.py via PIL.Image.open)
|
|
33
|
+
SUPPORTED_INPUT_FORMATS = (
|
|
34
|
+
".png",
|
|
35
|
+
".jpg",
|
|
36
|
+
".jpeg",
|
|
37
|
+
".webp",
|
|
38
|
+
".bmp",
|
|
39
|
+
".gif",
|
|
40
|
+
".tiff",
|
|
41
|
+
".tif",
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _sanitize_filename(name: str) -> str:
|
|
46
|
+
"""Sanitize a marker name for use in filenames.
|
|
47
|
+
|
|
48
|
+
Lowercase, replace whitespace and any char not in [a-z0-9_-] with _,
|
|
49
|
+
collapse runs of underscores.
|
|
50
|
+
"""
|
|
51
|
+
name = name.lower().strip()
|
|
52
|
+
name = re.sub(r"[^a-z0-9_-]", "_", name)
|
|
53
|
+
name = re.sub(r"_+", "_", name)
|
|
54
|
+
return name or "unnamed"
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _write_split_layers(
|
|
58
|
+
input_path: Path,
|
|
59
|
+
output_path: Path,
|
|
60
|
+
params,
|
|
61
|
+
marker_palette,
|
|
62
|
+
color_map,
|
|
63
|
+
color_map_used: dict,
|
|
64
|
+
) -> None:
|
|
65
|
+
"""Write one SVG file per color layer alongside the main output."""
|
|
66
|
+
from hatchsvg.core import RenderParams
|
|
67
|
+
|
|
68
|
+
stem = output_path.stem
|
|
69
|
+
out_dir = output_path.parent
|
|
70
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
71
|
+
|
|
72
|
+
# Track used slugs to handle collisions (e.g. two layers named "blue")
|
|
73
|
+
used_slugs: dict[str, int] = {}
|
|
74
|
+
layer_idx = 0
|
|
75
|
+
|
|
76
|
+
for pal_hex, entry in color_map_used.items():
|
|
77
|
+
marker_name = entry.get("marker_name", "unnamed") or "unnamed"
|
|
78
|
+
slug = _sanitize_filename(marker_name)
|
|
79
|
+
|
|
80
|
+
# Handle slug collisions by appending -N
|
|
81
|
+
if slug in used_slugs:
|
|
82
|
+
used_slugs[slug] += 1
|
|
83
|
+
slug = f"{slug}-{used_slugs[slug]}"
|
|
84
|
+
else:
|
|
85
|
+
used_slugs[slug] = 1
|
|
86
|
+
|
|
87
|
+
# Determine hatch_angle for this layer
|
|
88
|
+
if params.hatch_angles:
|
|
89
|
+
angles = params.hatch_angles
|
|
90
|
+
angle = angles[min(layer_idx, len(angles) - 1)] if angles else 0.0
|
|
91
|
+
else:
|
|
92
|
+
angle = 0.0
|
|
93
|
+
|
|
94
|
+
# Build per-layer params
|
|
95
|
+
layer_params = RenderParams(**{**params.__dict__, "hatch_angles": params.hatch_angles, "hatch_angle": angle})
|
|
96
|
+
|
|
97
|
+
# Compute the layer index format (2 digits for <100 layers, 3 for 100+)
|
|
98
|
+
idx_fmt = "03" if len(color_map_used) > 99 else "02"
|
|
99
|
+
filename = f"{stem}_{layer_idx:{idx_fmt}}_{slug}.svg"
|
|
100
|
+
layer_path = out_dir / filename
|
|
101
|
+
|
|
102
|
+
svg_content = render_single_layer_svg(
|
|
103
|
+
input_path=input_path,
|
|
104
|
+
params=layer_params,
|
|
105
|
+
marker_palette=marker_palette,
|
|
106
|
+
color_map=color_map,
|
|
107
|
+
pal_hex=pal_hex,
|
|
108
|
+
output_path=layer_path,
|
|
109
|
+
)
|
|
110
|
+
if svg_content is not None:
|
|
111
|
+
print(f" Split layer: {layer_path}")
|
|
112
|
+
|
|
113
|
+
layer_idx += 1
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def main():
|
|
117
|
+
"""Entry point for the `hatchsvg` console script."""
|
|
118
|
+
formats_help = " | ".join(SUPPORTED_INPUT_FORMATS)
|
|
119
|
+
_examples = (
|
|
120
|
+
"\nExamples:\n"
|
|
121
|
+
"```\n"
|
|
122
|
+
"# Quick default conversion\n"
|
|
123
|
+
"hatchsvg photo.jpg out.svg\n"
|
|
124
|
+
"\n"
|
|
125
|
+
"# Preset with override\n"
|
|
126
|
+
"hatchsvg drawing.png out.svg --preset logo --line-step 2\n"
|
|
127
|
+
"\n"
|
|
128
|
+
"# Custom marker palette\n"
|
|
129
|
+
"hatchsvg photo.jpg out.svg --palette-file markers.json\n"
|
|
130
|
+
"\n"
|
|
131
|
+
"# Split layers and preview\n"
|
|
132
|
+
"hatchsvg photo.jpg out.svg --split-layers --preview\n"
|
|
133
|
+
"\n"
|
|
134
|
+
"# Session reproducibility\n"
|
|
135
|
+
"hatchsvg photo.jpg out.svg --save-session && \\\n"
|
|
136
|
+
" hatchsvg photo.jpg out2.svg --use-session out.svg.session.json\n"
|
|
137
|
+
"```\n"
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
p = argparse.ArgumentParser(
|
|
141
|
+
prog="hatchsvg",
|
|
142
|
+
description=(
|
|
143
|
+
f"Convert images to hatched SVG files for Cricut pen plotters. Accepts input formats: {formats_help}."
|
|
144
|
+
),
|
|
145
|
+
epilog=(
|
|
146
|
+
"Presets:\n"
|
|
147
|
+
+ "\n".join(f" {name}: {spec['description']}" for name, spec in sorted(PRESETS.items()))
|
|
148
|
+
+ "\n"
|
|
149
|
+
+ _examples
|
|
150
|
+
),
|
|
151
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
152
|
+
)
|
|
153
|
+
p.add_argument("input", help=f"Path to input image ({formats_help})")
|
|
154
|
+
p.add_argument("output_svg", help="Path to output SVG file")
|
|
155
|
+
p.add_argument(
|
|
156
|
+
"--preset",
|
|
157
|
+
choices=list_presets(),
|
|
158
|
+
default=None,
|
|
159
|
+
help=(
|
|
160
|
+
"Named preset for common use cases (portrait, logo, line-art, photo, sketch, fast). "
|
|
161
|
+
"Preset values are applied first, then any explicit flags override them."
|
|
162
|
+
),
|
|
163
|
+
)
|
|
164
|
+
p.add_argument(
|
|
165
|
+
"--version",
|
|
166
|
+
action="version",
|
|
167
|
+
version=f"hatchsvg {__version__}",
|
|
168
|
+
)
|
|
169
|
+
p.add_argument(
|
|
170
|
+
"--max-palette",
|
|
171
|
+
type=int,
|
|
172
|
+
default=12,
|
|
173
|
+
help="Maximum number of palette colors (default: 12)",
|
|
174
|
+
)
|
|
175
|
+
p.add_argument(
|
|
176
|
+
"--line-step",
|
|
177
|
+
type=int,
|
|
178
|
+
default=4,
|
|
179
|
+
help="Line step size for hatching (default: 4)",
|
|
180
|
+
)
|
|
181
|
+
p.add_argument(
|
|
182
|
+
"--alpha-threshold",
|
|
183
|
+
type=int,
|
|
184
|
+
default=10,
|
|
185
|
+
help="Alpha threshold for visibility (default: 10)",
|
|
186
|
+
)
|
|
187
|
+
p.add_argument(
|
|
188
|
+
"--min-pixels",
|
|
189
|
+
type=int,
|
|
190
|
+
default=200,
|
|
191
|
+
help="Minimum pixels for a layer (default: 200)",
|
|
192
|
+
)
|
|
193
|
+
p.add_argument(
|
|
194
|
+
"--stroke-width",
|
|
195
|
+
type=float,
|
|
196
|
+
default=None,
|
|
197
|
+
help="Stroke width for hatch lines (default: auto)",
|
|
198
|
+
)
|
|
199
|
+
p.add_argument(
|
|
200
|
+
"--outline-width",
|
|
201
|
+
type=float,
|
|
202
|
+
default=None,
|
|
203
|
+
help="Outline stroke width (default: auto)",
|
|
204
|
+
)
|
|
205
|
+
p.add_argument(
|
|
206
|
+
"--no-skip-background",
|
|
207
|
+
action="store_true",
|
|
208
|
+
help="Include background layer in output",
|
|
209
|
+
)
|
|
210
|
+
p.add_argument(
|
|
211
|
+
"--white-medium",
|
|
212
|
+
action="store_true",
|
|
213
|
+
help="Use white as a medium color instead of skipping near-white",
|
|
214
|
+
)
|
|
215
|
+
p.add_argument(
|
|
216
|
+
"--paper-white-soft",
|
|
217
|
+
type=int,
|
|
218
|
+
default=20,
|
|
219
|
+
help="Soft threshold for paper white (default: 20)",
|
|
220
|
+
)
|
|
221
|
+
p.add_argument(
|
|
222
|
+
"--scale",
|
|
223
|
+
type=float,
|
|
224
|
+
default=1.0,
|
|
225
|
+
help="Scale factor for output (default: 1.0)",
|
|
226
|
+
)
|
|
227
|
+
p.add_argument(
|
|
228
|
+
"--separate-outline",
|
|
229
|
+
action="store_true",
|
|
230
|
+
help="Generate separate outline paths",
|
|
231
|
+
)
|
|
232
|
+
p.add_argument("--palette-file", help="Path to marker palette JSON file")
|
|
233
|
+
p.add_argument(
|
|
234
|
+
"--naming-mode",
|
|
235
|
+
choices=["inkscape", "flat"],
|
|
236
|
+
default="inkscape",
|
|
237
|
+
help="Layer naming mode (default: inkscape)",
|
|
238
|
+
)
|
|
239
|
+
p.add_argument(
|
|
240
|
+
"--continuous-paths",
|
|
241
|
+
action="store_true",
|
|
242
|
+
help="Generate continuous serpentine paths to reduce pen plotter vibration",
|
|
243
|
+
)
|
|
244
|
+
p.add_argument(
|
|
245
|
+
"--arc-radius",
|
|
246
|
+
type=float,
|
|
247
|
+
default=0.0,
|
|
248
|
+
help="Add arc smoothing at row-end 180° reversals (0 = disabled)",
|
|
249
|
+
)
|
|
250
|
+
p.add_argument(
|
|
251
|
+
"--save-session",
|
|
252
|
+
action="store_true",
|
|
253
|
+
help="Save session JSON for reproducible runs",
|
|
254
|
+
)
|
|
255
|
+
p.add_argument("--use-session", help="Load previous session JSON for reproducible output")
|
|
256
|
+
p.add_argument("--progress", action="store_true", help="Show progress bars (requires rich)")
|
|
257
|
+
p.add_argument("--stats", action="store_true", help="Show processing statistics")
|
|
258
|
+
p.add_argument(
|
|
259
|
+
"--preview",
|
|
260
|
+
action="store_true",
|
|
261
|
+
help="Open output SVG in default viewer after render",
|
|
262
|
+
)
|
|
263
|
+
p.add_argument(
|
|
264
|
+
"--split-layers",
|
|
265
|
+
action="store_true",
|
|
266
|
+
help="Also write one SVG file per color layer to <stem>_<NN>_<color>.svg",
|
|
267
|
+
)
|
|
268
|
+
p.add_argument(
|
|
269
|
+
"--optimize-travel",
|
|
270
|
+
action="store_true",
|
|
271
|
+
help="Reorder layers to minimize pen-up travel distance",
|
|
272
|
+
)
|
|
273
|
+
p.add_argument(
|
|
274
|
+
"--hatch-angles",
|
|
275
|
+
type=str,
|
|
276
|
+
default=None,
|
|
277
|
+
help=(
|
|
278
|
+
"Comma-separated hatch angles per layer in degrees. "
|
|
279
|
+
"If fewer angles than layers, the last value is reused. "
|
|
280
|
+
"Example: --hatch-angles=0,45,90,135"
|
|
281
|
+
),
|
|
282
|
+
)
|
|
283
|
+
|
|
284
|
+
a = p.parse_args()
|
|
285
|
+
|
|
286
|
+
# Attach the parser to args so core._extract_explicit_args can diff
|
|
287
|
+
# explicit CLI flags against parser defaults (used by --preset).
|
|
288
|
+
a._hatchsvg_parser = p # type: ignore[attr-defined]
|
|
289
|
+
|
|
290
|
+
# Check for Rich if --progress is requested
|
|
291
|
+
if a.progress and not HAS_RICH:
|
|
292
|
+
print("Warning: --progress requires 'rich' package. Install with: pip install rich")
|
|
293
|
+
|
|
294
|
+
input_path = Path(a.input)
|
|
295
|
+
output_path = Path(a.output_svg)
|
|
296
|
+
|
|
297
|
+
# Validate input format early with a friendly hint before Pillow tries to open it
|
|
298
|
+
if input_path.suffix.lower() not in SUPPORTED_INPUT_FORMATS:
|
|
299
|
+
print(
|
|
300
|
+
f"Error: unsupported input format '{input_path.suffix}'.",
|
|
301
|
+
file=sys.stderr,
|
|
302
|
+
)
|
|
303
|
+
print(
|
|
304
|
+
f"Supported formats: {', '.join(SUPPORTED_INPUT_FORMATS)}",
|
|
305
|
+
file=sys.stderr,
|
|
306
|
+
)
|
|
307
|
+
sys.exit(1)
|
|
308
|
+
|
|
309
|
+
# Parse --hatch-angles into a list of floats
|
|
310
|
+
hatch_angles: list[float] = []
|
|
311
|
+
if a.hatch_angles is not None:
|
|
312
|
+
raw = a.hatch_angles.strip()
|
|
313
|
+
if raw:
|
|
314
|
+
try:
|
|
315
|
+
hatch_angles = [float(v) for v in raw.split(",")]
|
|
316
|
+
except ValueError:
|
|
317
|
+
print(
|
|
318
|
+
f"Error: --hatch-angles must be comma-separated numbers, got: {a.hatch_angles!r}",
|
|
319
|
+
file=sys.stderr,
|
|
320
|
+
)
|
|
321
|
+
sys.exit(1)
|
|
322
|
+
# Empty string → treat as default (no rotation)
|
|
323
|
+
|
|
324
|
+
# Load configuration — wrap with friendly error handling.
|
|
325
|
+
# If --preset is set, the preset's overrides are applied as a base, then
|
|
326
|
+
# any explicit CLI flags on top of it. So `hatchsvg img out --preset logo
|
|
327
|
+
# --line-step 2` gives a logo preset with line_step=2.
|
|
328
|
+
try:
|
|
329
|
+
params, marker_palette, color_map, palette_file = get_run_configuration(a, preset_name=a.preset)
|
|
330
|
+
except ValueError as e:
|
|
331
|
+
print(f"Error: {e}", file=sys.stderr)
|
|
332
|
+
print(
|
|
333
|
+
"\nHint: check that your --palette-file is a valid JSON palette with a 'colors' list.",
|
|
334
|
+
file=sys.stderr,
|
|
335
|
+
)
|
|
336
|
+
sys.exit(1)
|
|
337
|
+
except FileNotFoundError as e:
|
|
338
|
+
print(f"Error: file not found: {e.filename}", file=sys.stderr)
|
|
339
|
+
sys.exit(1)
|
|
340
|
+
|
|
341
|
+
# Override hatch_angles on params if the user specified --hatch-angles
|
|
342
|
+
if hatch_angles:
|
|
343
|
+
params.hatch_angles = hatch_angles
|
|
344
|
+
|
|
345
|
+
# Run Process — wrap with friendly error handling
|
|
346
|
+
try:
|
|
347
|
+
color_map_used, processing_stats = process_image_to_hatched_svg(
|
|
348
|
+
input_path,
|
|
349
|
+
output_path,
|
|
350
|
+
params,
|
|
351
|
+
marker_palette,
|
|
352
|
+
color_map,
|
|
353
|
+
show_progress=a.progress,
|
|
354
|
+
show_stats=a.stats,
|
|
355
|
+
optimize_travel=a.optimize_travel,
|
|
356
|
+
)
|
|
357
|
+
except SystemExit as e:
|
|
358
|
+
# core.py raises SystemExit with cryptic strings; translate them
|
|
359
|
+
msg = str(e.code) if e.code is not None else ""
|
|
360
|
+
hint = _ERROR_HINTS.get(msg)
|
|
361
|
+
if hint:
|
|
362
|
+
print(f"Error: {msg}\n\n{hint}", file=sys.stderr)
|
|
363
|
+
else:
|
|
364
|
+
print(f"Error: {msg}", file=sys.stderr)
|
|
365
|
+
sys.exit(1)
|
|
366
|
+
except ValueError as e:
|
|
367
|
+
# Palette loading / invalid input
|
|
368
|
+
print(f"Error: {e}", file=sys.stderr)
|
|
369
|
+
print(
|
|
370
|
+
"\nHint: check that your --palette-file is a valid JSON palette with a 'colors' list.",
|
|
371
|
+
file=sys.stderr,
|
|
372
|
+
)
|
|
373
|
+
sys.exit(1)
|
|
374
|
+
except FileNotFoundError as e:
|
|
375
|
+
print(f"Error: file not found: {e.filename}", file=sys.stderr)
|
|
376
|
+
sys.exit(1)
|
|
377
|
+
|
|
378
|
+
# Display stats if requested
|
|
379
|
+
if a.stats:
|
|
380
|
+
_display_stats_table(processing_stats)
|
|
381
|
+
|
|
382
|
+
# --split-layers: write one SVG per color layer
|
|
383
|
+
if a.split_layers:
|
|
384
|
+
_write_split_layers(
|
|
385
|
+
input_path=input_path,
|
|
386
|
+
output_path=output_path,
|
|
387
|
+
params=params,
|
|
388
|
+
marker_palette=marker_palette,
|
|
389
|
+
color_map=color_map,
|
|
390
|
+
color_map_used=color_map_used,
|
|
391
|
+
)
|
|
392
|
+
|
|
393
|
+
if a.save_session:
|
|
394
|
+
session_out_path = output_path.with_suffix(output_path.suffix + ".session.json")
|
|
395
|
+
save_session(session_out_path, input_path, palette_file, params, color_map_used)
|
|
396
|
+
|
|
397
|
+
# --preview: open the main output SVG in the default viewer
|
|
398
|
+
if a.preview:
|
|
399
|
+
try:
|
|
400
|
+
uri = output_path.resolve().as_uri()
|
|
401
|
+
opened = webbrowser.open(uri)
|
|
402
|
+
if not opened:
|
|
403
|
+
print(f"Warning: could not open browser for {uri}", file=sys.stderr)
|
|
404
|
+
except Exception as exc:
|
|
405
|
+
print(f"Warning: --preview failed: {exc}", file=sys.stderr)
|
|
406
|
+
if a.split_layers:
|
|
407
|
+
print("Note: --preview opens the main output only (not split-layer files)", file=sys.stderr)
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
if __name__ == "__main__":
|
|
411
|
+
main()
|