icoft 0.4.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.
icoft/__init__.py ADDED
File without changes
icoft/cli.py ADDED
@@ -0,0 +1,480 @@
1
+ """Icoft - From Single Image to Full-Platform App Icons or use -o png/svg to output intermediate picture for checking preprocessing results
2
+
3
+ Examples:
4
+ # Generate full icon set
5
+ icoft source_file.png dest_dir/
6
+
7
+ # Crop and generate icons
8
+ icoft -c 10% source_file.png dest_dir/
9
+
10
+ # Crop + background removal + icons
11
+ icoft -c 10% -a source_file.png dest_dir/
12
+
13
+ # Output single processed PNG
14
+ icoft -c 10% source_file.png output.png -o png
15
+
16
+ # Output single SVG (auto-vectorizes)
17
+ icoft source_file.png output.svg -o svg
18
+ """
19
+
20
+ from importlib.metadata import version
21
+
22
+ import click
23
+ from rich.console import Console
24
+
25
+ console = Console()
26
+
27
+ # Get version from package metadata
28
+ __version__ = version("icoft")
29
+
30
+
31
+ @click.command(
32
+ context_settings={"help_option_names": ["-h", "--help"]},
33
+ help="From Single Image to Full-Platform App Icons, use -o png/svg to output intermediate PNG/SVG for checking preprocessing results.",
34
+ )
35
+ @click.argument("source_file", type=click.Path(exists=True), required=False, metavar="SOURCE_FILE")
36
+ @click.argument("dest_dir", type=click.Path(), required=False, metavar="DEST_DIR|DEST_FILE")
37
+ @click.option(
38
+ "-a",
39
+ "use_ai_bg",
40
+ is_flag=True,
41
+ default=False,
42
+ help="Use AI (U²-Net) for background removal",
43
+ )
44
+ @click.option(
45
+ "-b",
46
+ "--bg-threshold",
47
+ "bg_threshold",
48
+ type=int,
49
+ default=0,
50
+ help="Enable simple color-based background removal with threshold (0-255)",
51
+ )
52
+ @click.option(
53
+ "-c",
54
+ "--crop-margin",
55
+ "crop_margin",
56
+ type=str,
57
+ default=None,
58
+ help="Margin for cropping (e.g., 5%, 10px)",
59
+ )
60
+ @click.option(
61
+ "-o",
62
+ "--output",
63
+ "output_format",
64
+ type=click.Choice(["png", "svg", "icon"]),
65
+ default="icon",
66
+ help="Output format: icon (directory, default), png (single file), svg (single file)",
67
+ )
68
+ @click.option(
69
+ "-p",
70
+ "--platforms",
71
+ type=str,
72
+ default="all",
73
+ help="Comma-separated platforms: windows, macos, linux, web (default: all)",
74
+ )
75
+ @click.option(
76
+ "-s",
77
+ "--svg",
78
+ "svg_mode",
79
+ type=click.Choice(["normal", "embed"]),
80
+ default=None,
81
+ help="Enable SVG output: normal (vector tracing, default for lossless scaling) or embed (PNG in SVG, preserves gradients)",
82
+ )
83
+ @click.option(
84
+ "-S",
85
+ "--svg-speckle",
86
+ "svg_speckle",
87
+ type=int,
88
+ default=10,
89
+ help="Filter SVG noise (1-100, default: 10, only for normal mode)",
90
+ )
91
+ @click.option(
92
+ "-P",
93
+ "--svg-precision",
94
+ "svg_precision",
95
+ type=int,
96
+ default=6,
97
+ help="SVG color precision (1-16, default: 6, only for normal mode)",
98
+ )
99
+ @click.option("-V", "--version", "show_version", is_flag=True, help="Show version and exit")
100
+ def main(
101
+ source_file: str | None,
102
+ dest_dir: str | None,
103
+ use_ai_bg: bool,
104
+ bg_threshold: int,
105
+ crop_margin: str | None,
106
+ output_format: str,
107
+ platforms: str,
108
+ svg_mode: str | None,
109
+ svg_speckle: int,
110
+ svg_precision: int,
111
+ show_version: bool,
112
+ ) -> None:
113
+ # Handle --version flag
114
+ if show_version:
115
+ console.print(f"[bold blue]icoft[/bold blue] [dim]v{__version__}[/dim]")
116
+ return
117
+
118
+ # Validate required arguments with clear error messages
119
+ if source_file is None and dest_dir is None:
120
+ console.print("[red]Error:[/] Missing required arguments")
121
+ console.print(" [bold]SOURCE_FILE[/bold] and [bold]DEST_DIR[/bold] are required.")
122
+ console.print("\n[bold blue]Examples:[/]")
123
+ console.print(" icoft logo.png icons/ # Generate icons from original")
124
+ console.print(" icoft -m 10% logo.png icons/ # Crop + generate icons")
125
+ console.print(" icoft logo.png out.svg -o svg # Output single SVG")
126
+ console.print("\nUse [bold]-h[/bold] or [bold]--help[/bold] for more options.")
127
+ raise SystemExit(1)
128
+
129
+ if source_file is None:
130
+ console.print("[red]Error:[/] Missing [bold]SOURCE_FILE[/bold] argument")
131
+ console.print(" Please specify the input image file.")
132
+ console.print("\n[bold blue]Example:[/] icoft logo.png icons/")
133
+ raise SystemExit(1)
134
+
135
+ if dest_dir is None:
136
+ console.print("[red]Error:[/] Missing [bold]DEST_DIR[/bold] argument")
137
+ console.print(" Please specify the output directory or file path.")
138
+ console.print("\n[bold blue]Examples:[/]")
139
+ console.print(" icoft logo.png [bold]icons/[/bold] # Output to directory")
140
+ console.print(" icoft logo.png [bold]out.svg[/bold] -o svg # Output to single file")
141
+ raise SystemExit(1)
142
+
143
+ from pathlib import Path
144
+
145
+ input_path = Path(source_file)
146
+ output_path = Path(dest_dir)
147
+
148
+ # Validate source_file is a file, not a directory
149
+ if input_path.is_dir():
150
+ console.print(f"[red]Error:[/] Source file is a directory: {source_file}")
151
+ console.print(" Please specify an image file, not a directory.")
152
+ console.print("\n[bold blue]Examples:[/]")
153
+ console.print(" icoft logo.png icons/ # Process single image")
154
+ console.print(" icoft -a logo.png output/ # AI background removal")
155
+ raise SystemExit(1)
156
+
157
+ # Get base filename without extension for output naming
158
+ base_filename = input_path.stem
159
+
160
+ # Smart 判断:是单文件输出还是目录输出?
161
+ # 规则:
162
+ # 1. 如果 dest_dir 以 / 结尾 → 目录
163
+ # 2. 如果 dest_dir 有图片扩展名 (.png, .jpg, .svg) → 文件
164
+ # 3. 否则 → 目录
165
+ is_single_file = dest_dir.endswith("/") is False and output_path.suffix.lower() in [
166
+ ".png",
167
+ ".jpg",
168
+ ".jpeg",
169
+ ".svg",
170
+ ".ico",
171
+ ".icns",
172
+ ]
173
+
174
+ # 创建输出目录(单文件输出时创建父目录)
175
+ if is_single_file:
176
+ output_path.parent.mkdir(parents=True, exist_ok=True)
177
+ else:
178
+ output_path.mkdir(parents=True, exist_ok=True)
179
+
180
+ console.print("[bold blue]Icoft - Icon Forge[/bold blue]")
181
+ console.print(f"Processing: {input_path}")
182
+ console.print(f"Output: {output_path} ({'single file' if is_single_file else 'directory'})\n")
183
+
184
+ # Check if any step flag was explicitly provided
185
+ any_step_flagged = any(
186
+ [
187
+ crop_margin is not None,
188
+ bg_threshold != 0,
189
+ use_ai_bg,
190
+ svg_mode is not None,
191
+ svg_speckle != 10,
192
+ svg_precision != 6,
193
+ ]
194
+ )
195
+
196
+ # Determine which steps to execute
197
+ if not any_step_flagged:
198
+ # No flags - default: NO processing, only generate icons from original image
199
+ crop_margin = None
200
+ crop_enabled = False
201
+ transparent_enabled = False
202
+ svg_mode = None
203
+ else:
204
+ # Enable steps based on which parameters were provided
205
+ # -B → simple background removal, -A → AI background removal
206
+ transparent_enabled = bg_threshold != 0 or use_ai_bg
207
+
208
+ # Auto-enable steps based on parameter flags
209
+ crop_enabled = crop_margin is not None
210
+ if svg_mode is None and (svg_speckle != 10 or svg_precision != 6):
211
+ svg_mode = "normal"
212
+
213
+ # --output=svg should auto-enable vectorization
214
+ if output_format == "svg" and svg_mode is None:
215
+ svg_mode = "normal"
216
+ # Also enable transparent background if no other processing specified
217
+ if not transparent_enabled:
218
+ transparent_enabled = True
219
+
220
+ # Validate mutually exclusive parameters
221
+ if svg_mode == "embed" and (svg_speckle != 10 or svg_precision != 6):
222
+ console.print(
223
+ "[red]Error:[/] -S/--svg-speckle and -P/--svg-precision are only valid for 'normal' mode"
224
+ )
225
+ console.print(
226
+ "These parameters control vtracer settings, which are not used in 'embed' mode"
227
+ )
228
+ return
229
+
230
+ # Check for cairosvg if using normal mode with icon generation
231
+ if svg_mode == "normal" and output_format == "icon":
232
+ import importlib.util
233
+
234
+ if importlib.util.find_spec("cairosvg") is None:
235
+ console.print("[yellow]Warning:[/] cairosvg is not installed.")
236
+ console.print("[dim]Icons will be generated using standard bitmap scaling.[/dim]")
237
+ console.print(
238
+ "[dim]For higher quality vector-based icons, install with: uv sync --extra vector[/dim]"
239
+ )
240
+
241
+ # Priority 4: Determine output format
242
+ # --output=icon (default) → generate icons
243
+ # --output=png → save last processing step as PNG
244
+ # --output=svg → save last processing step as SVG
245
+ icon_enabled = output_format == "icon"
246
+
247
+ try:
248
+ from icoft.core.processor import ImageProcessor
249
+
250
+ processor = ImageProcessor(input_path)
251
+
252
+ # Determine if background processing is enabled
253
+ transparent_enabled = bg_threshold != 0 or use_ai_bg
254
+
255
+ # Determine the last step to save the final output
256
+ # When --output=icon, always generate icons regardless of processing steps
257
+ if icon_enabled:
258
+ last_step = "icon"
259
+ elif output_format == "svg":
260
+ last_step = "svg"
261
+ elif output_format == "png":
262
+ if svg_mode is not None:
263
+ last_step = "svg"
264
+ elif transparent_enabled:
265
+ last_step = "transparent"
266
+ elif crop_enabled:
267
+ last_step = "crop"
268
+ else:
269
+ last_step = "original"
270
+ else:
271
+ last_step = "icon"
272
+
273
+ # Crop borders
274
+ if crop_enabled:
275
+ console.print("[yellow]Cropping borders...[/]")
276
+ assert crop_margin is not None # Guaranteed by crop_enabled check
277
+ processor.crop_borders(margin=crop_margin)
278
+ console.print("[green]✓[/green] Borders cropped")
279
+
280
+ if last_step == "crop":
281
+ last_output_path = (
282
+ output_path if is_single_file else output_path / f"{base_filename}_cropped.png"
283
+ )
284
+ processor.save(last_output_path)
285
+ console.print(
286
+ f"\n[bold green]Success![/] Cropped image saved to: {last_output_path}"
287
+ )
288
+ return
289
+
290
+ # Background processing
291
+ # -B: Simple color-based method
292
+ # -A: AI-based method (U²-Net)
293
+ # -A -B: AI + refinement
294
+ if transparent_enabled:
295
+ if use_ai_bg:
296
+ # AI-based background removal
297
+ # Phase 1: Extract background color BEFORE AI (for optional refinement)
298
+ bg_color = None
299
+ if bg_threshold != 0: # -B specified with AI for refinement
300
+ console.print("[yellow]Extracting background color...[/]")
301
+ bg_color = processor.extract_background_color()
302
+ if bg_color is not None:
303
+ console.print(
304
+ f"[green]✓[/green] Background color extracted: {bg_color.astype(int)}"
305
+ )
306
+ else:
307
+ console.print(
308
+ "[yellow]⚠[/yellow] No background color detected, skipping refinement"
309
+ )
310
+
311
+ # Phase 2: AI-based background removal
312
+ console.print("[yellow]Removing background with AI (U²-Net)...[/]")
313
+ try:
314
+ processor.remove_background_ai()
315
+ console.print("[green]✓[/green] Background removed using AI (U²-Net)")
316
+ except ImportError as e:
317
+ console.print(f"[red]Error:[/] {e}")
318
+ console.print("[yellow]Tip:[/] Install with: uv sync --extra ai")
319
+ raise SystemExit(1) from None
320
+
321
+ # Phase 3: Optional color-based refinement AFTER AI
322
+ if bg_color is not None:
323
+ console.print(
324
+ f"[yellow]Applying color-based refinement (threshold={bg_threshold})...[/]"
325
+ )
326
+ processor.refine_transparency(bg_color=bg_color, tolerance=bg_threshold)
327
+ console.print(
328
+ "[green]✓[/green] Color-based refinement applied (skips already transparent pixels)"
329
+ )
330
+ else:
331
+ # Simple color-based background removal (-B flag)
332
+ threshold = bg_threshold if bg_threshold != 0 else 10 # Default to 10 if just -B
333
+ console.print("[yellow]Making background transparent...[/]")
334
+ processor.make_background_transparent(tolerance=threshold)
335
+ console.print(
336
+ f"[green]✓[/green] Background made transparent (color-based, threshold={threshold})"
337
+ )
338
+
339
+ if last_step == "transparent":
340
+ last_output_path = (
341
+ output_path if is_single_file else output_path / f"{base_filename}.png"
342
+ )
343
+ processor.save(last_output_path)
344
+ console.print(
345
+ f"\n[bold green]Success![/] Transparent PNG saved to: {last_output_path}"
346
+ )
347
+ return
348
+
349
+ # SVG generation (optional)
350
+ svg_content_for_generator = None
351
+ if svg_mode is not None:
352
+ if svg_mode == "embed":
353
+ # Embed PNG as base64 into SVG (preserves gradients perfectly)
354
+ console.print("[yellow]Generating SVG (embedded PNG)...[/]")
355
+ import base64
356
+ import io
357
+
358
+ img = processor.image.convert("RGBA")
359
+ buffer = io.BytesIO()
360
+ img.save(buffer, format="PNG")
361
+ png_base64 = base64.b64encode(buffer.getvalue()).decode("utf-8")
362
+
363
+ svg_result = f"""<?xml version="1.0" encoding="UTF-8"?>
364
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 {img.width} {img.height}" width="{img.width}" height="{img.height}">
365
+ <image href="data:image/png;base64,{png_base64}" width="{img.width}" height="{img.height}"/>
366
+ </svg>"""
367
+ else:
368
+ # Vector tracing with vtracer
369
+ console.print("[yellow]Vectorizing (PNG to SVG)...[/]")
370
+ try:
371
+ import re
372
+
373
+ import vtracer # type: ignore[import-untyped]
374
+
375
+ img = processor.image.convert("RGBA")
376
+ pixels = list(img.getdata()) # type: ignore[arg-type]
377
+
378
+ svg_result = vtracer.convert_pixels_to_svg(
379
+ pixels,
380
+ img.size,
381
+ colormode="color",
382
+ hierarchical="stacked",
383
+ mode="spline",
384
+ filter_speckle=svg_speckle,
385
+ color_precision=svg_precision,
386
+ corner_threshold=60,
387
+ length_threshold=4.0,
388
+ splice_threshold=45,
389
+ )
390
+
391
+ # Fix viewBox: vtracer doesn't set it, causing display issues with transforms
392
+ # The paths start from (0,0) and are translated by transform attribute
393
+ # So viewBox should be from (0,0) with image dimensions
394
+ if "viewBox" not in svg_result:
395
+ # Add simple viewBox starting from origin and red background
396
+ svg_result = re.sub(
397
+ r"<svg([^>]*)>",
398
+ f'<svg\\1 viewBox="0 0 {img.width} {img.height}">\n<rect width="{img.width}" height="{img.height}" fill="#FF0000"/>',
399
+ svg_result,
400
+ count=1,
401
+ )
402
+
403
+ # For normal mode, save the SVG content for high-quality icon generation
404
+ svg_content_for_generator = svg_result
405
+ except ImportError:
406
+ console.print("[red]Error:[/] Vectorization requires 'vtracer' package")
407
+ console.print("[dim]Install with: pip install vtracer[/dim]")
408
+ return
409
+
410
+ if is_single_file:
411
+ last_output_path = output_path
412
+ else:
413
+ last_output_path = output_path / f"{base_filename}.svg"
414
+ last_output_path.parent.mkdir(parents=True, exist_ok=True)
415
+ last_output_path.write_text(svg_result, encoding="utf-8")
416
+ console.print(f"[green]✓[/green] SVG generation complete (mode: {svg_mode})")
417
+ console.print(f"[dim]✓[/dim] Saved: {last_output_path.name}")
418
+
419
+ # Save final output if this is the last step
420
+ if last_step == "svg":
421
+ if output_format == "png":
422
+ # Save SVG result as PNG (rasterize)
423
+ # For now, just save the PNG before vectorization
424
+ last_output_path = (
425
+ output_path if is_single_file else output_path / f"{base_filename}.png"
426
+ )
427
+ processor.save(last_output_path)
428
+ console.print(f"\n[bold green]Success![/] PNG saved to: {last_output_path}")
429
+ else:
430
+ console.print(f"\n[bold green]Success![/] SVG saved to: {last_output_path}")
431
+ return
432
+
433
+ # Generate icons (default) or save PNG
434
+ if icon_enabled:
435
+ from icoft.core.generator import IconGenerator
436
+
437
+ generator = IconGenerator(
438
+ processor.image, output_path, svg_content=svg_content_for_generator
439
+ )
440
+
441
+ platform_list = (
442
+ platforms.split(",") if platforms != "all" else ["windows", "macos", "linux", "web"]
443
+ )
444
+
445
+ for platform in platform_list:
446
+ platform = platform.strip().lower()
447
+ if platform == "windows":
448
+ console.print("[yellow]Generating:[/] Windows icons...")
449
+ generator.generate_windows()
450
+ console.print("[green]✓[/green] Windows icons generated")
451
+ elif platform == "macos":
452
+ console.print("[yellow]Generating:[/] macOS icons...")
453
+ generator.generate_macos()
454
+ console.print("[green]✓[/green] macOS icons generated")
455
+ elif platform == "linux":
456
+ console.print("[yellow]Generating:[/] Linux icons...")
457
+ generator.generate_linux()
458
+ console.print("[green]✓[/green] Linux icons generated")
459
+ elif platform == "web":
460
+ console.print("[yellow]Generating:[/] Web icons...")
461
+ generator.generate_web()
462
+ console.print("[green]✓[/green] Web icons generated")
463
+ else:
464
+ console.print(f"[red]Warning:[/] Unknown platform: {platform}")
465
+
466
+ console.print(f"\n[bold green]Success![/] All icons generated in: {output_path}")
467
+
468
+ elif output_format == "png" and last_step == "original":
469
+ # --output=png with no processing steps: save original as PNG
470
+ last_output_path = output_path if is_single_file else output_path / "original.png"
471
+ processor.save(last_output_path)
472
+ console.print(f"\n[bold green]Success![/] Original image saved to: {last_output_path}")
473
+
474
+ except Exception as e:
475
+ console.print(f"[red]Error:[/] {str(e)}")
476
+ raise
477
+
478
+
479
+ if __name__ == "__main__":
480
+ main()
icoft/core/__init__.py ADDED
File without changes