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 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
@@ -0,0 +1,6 @@
1
+ """Allow `python -m hatchsvg` to invoke the CLI."""
2
+
3
+ from hatchsvg.cli import main
4
+
5
+ if __name__ == "__main__":
6
+ raise SystemExit(main())
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()