pixopt 1.0.5__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.
- pixopt/__init__.py +19 -0
- pixopt/adaptive_quality.py +103 -0
- pixopt/cli/__init__.py +17 -0
- pixopt/cli/app.py +14 -0
- pixopt/cli/commands/__init__.py +14 -0
- pixopt/cli/commands/batch.py +90 -0
- pixopt/cli/commands/compare.py +94 -0
- pixopt/cli/commands/convert.py +110 -0
- pixopt/cli/commands/favicon.py +52 -0
- pixopt/cli/commands/info.py +44 -0
- pixopt/cli/commands/optimize.py +159 -0
- pixopt/cli/commands/placeholder.py +56 -0
- pixopt/cli/commands/srcset.py +132 -0
- pixopt/cli/options.py +123 -0
- pixopt/cli/output.py +73 -0
- pixopt/constants.py +45 -0
- pixopt/format_resolver.py +35 -0
- pixopt/html_comparison.py +200 -0
- pixopt/image_ops.py +133 -0
- pixopt/models.py +57 -0
- pixopt/optimizer.py +474 -0
- pixopt/placeholder.py +132 -0
- pixopt/smart_format.py +101 -0
- pixopt/srcset_generator.py +102 -0
- pixopt/svg_optimizer.py +81 -0
- pixopt/utils.py +22 -0
- pixopt-1.0.5.dist-info/METADATA +304 -0
- pixopt-1.0.5.dist-info/RECORD +31 -0
- pixopt-1.0.5.dist-info/WHEEL +4 -0
- pixopt-1.0.5.dist-info/entry_points.txt +2 -0
- pixopt-1.0.5.dist-info/licenses/LICENSE +21 -0
pixopt/__init__.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""pixopt - Fast Python image optimizer. Resize, compress, convert,
|
|
2
|
+
and generate responsive assets."""
|
|
3
|
+
|
|
4
|
+
from pixopt.models import OptimizationResult
|
|
5
|
+
from pixopt.optimizer import (
|
|
6
|
+
change_extension,
|
|
7
|
+
convert_to_favicon,
|
|
8
|
+
optimize_directory,
|
|
9
|
+
optimize_image,
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
__version__ = "1.0.5"
|
|
13
|
+
__all__ = [
|
|
14
|
+
"OptimizationResult",
|
|
15
|
+
"change_extension",
|
|
16
|
+
"convert_to_favicon",
|
|
17
|
+
"optimize_directory",
|
|
18
|
+
"optimize_image",
|
|
19
|
+
]
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"""Adaptive quality finder using binary search to hit a target file size."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from io import BytesIO
|
|
6
|
+
|
|
7
|
+
from PIL import Image
|
|
8
|
+
|
|
9
|
+
from pixopt.image_ops import build_save_kwargs, convert_mode, resize_image
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def find_quality_for_target_size(
|
|
13
|
+
img: Image.Image,
|
|
14
|
+
pillow_fmt: str,
|
|
15
|
+
target_size: int,
|
|
16
|
+
*,
|
|
17
|
+
max_width: int | None = None,
|
|
18
|
+
max_height: int | None = None,
|
|
19
|
+
keep_aspect_ratio: bool = True,
|
|
20
|
+
strip_metadata: bool = True,
|
|
21
|
+
progressive: bool = True,
|
|
22
|
+
optimize: bool = True,
|
|
23
|
+
lossless: bool = False,
|
|
24
|
+
min_quality: int = 1,
|
|
25
|
+
max_quality: int = 100,
|
|
26
|
+
tolerance: float = 0.05,
|
|
27
|
+
max_iterations: int = 8,
|
|
28
|
+
) -> int:
|
|
29
|
+
"""Find the JPEG/WEBP quality that produces a file closest to target_size.
|
|
30
|
+
|
|
31
|
+
Uses binary search over quality (1-100) and measures the actual encoded
|
|
32
|
+
file size in memory. Returns the quality value that yields a size
|
|
33
|
+
closest to but not exceeding the target.
|
|
34
|
+
|
|
35
|
+
Args:
|
|
36
|
+
img: Open PIL Image.
|
|
37
|
+
pillow_fmt: Target Pillow format (JPEG or WEBP).
|
|
38
|
+
target_size: Target file size in bytes.
|
|
39
|
+
max_width: Maximum width in pixels, or None.
|
|
40
|
+
max_height: Maximum height in pixels, or None.
|
|
41
|
+
keep_aspect_ratio: Whether to keep the original aspect ratio.
|
|
42
|
+
strip_metadata: Whether to strip metadata before saving.
|
|
43
|
+
progressive: Whether to use progressive encoding.
|
|
44
|
+
optimize: Whether to optimize the output.
|
|
45
|
+
lossless: Whether to use lossless compression.
|
|
46
|
+
min_quality: Lowest quality to try.
|
|
47
|
+
max_quality: Highest quality to try.
|
|
48
|
+
tolerance: Fractional tolerance around target_size (e.g. 0.05 = 5%).
|
|
49
|
+
max_iterations: Maximum binary-search iterations.
|
|
50
|
+
|
|
51
|
+
Returns:
|
|
52
|
+
Quality integer (1-100).
|
|
53
|
+
"""
|
|
54
|
+
if pillow_fmt not in ("JPEG", "WEBP"):
|
|
55
|
+
return 85
|
|
56
|
+
|
|
57
|
+
working = img.copy()
|
|
58
|
+
working = convert_mode(working, pillow_fmt)
|
|
59
|
+
working = resize_image(
|
|
60
|
+
working,
|
|
61
|
+
max_width=max_width,
|
|
62
|
+
max_height=max_height,
|
|
63
|
+
keep_aspect_ratio=keep_aspect_ratio,
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
low = min_quality
|
|
67
|
+
high = max_quality
|
|
68
|
+
best_quality = low
|
|
69
|
+
best_diff = float("inf")
|
|
70
|
+
|
|
71
|
+
for _ in range(max_iterations):
|
|
72
|
+
if low > high:
|
|
73
|
+
break
|
|
74
|
+
mid = (low + high) // 2
|
|
75
|
+
|
|
76
|
+
buf = BytesIO()
|
|
77
|
+
kwargs = build_save_kwargs(
|
|
78
|
+
pillow_fmt,
|
|
79
|
+
quality=mid,
|
|
80
|
+
progressive=progressive,
|
|
81
|
+
optimize=optimize,
|
|
82
|
+
strip_metadata=strip_metadata,
|
|
83
|
+
lossless=lossless,
|
|
84
|
+
)
|
|
85
|
+
working.save(buf, format=pillow_fmt, **kwargs)
|
|
86
|
+
size = buf.tell()
|
|
87
|
+
|
|
88
|
+
diff = abs(size - target_size)
|
|
89
|
+
if diff < best_diff:
|
|
90
|
+
best_diff = diff
|
|
91
|
+
best_quality = mid
|
|
92
|
+
|
|
93
|
+
# Within tolerance window?
|
|
94
|
+
if abs(size - target_size) <= target_size * tolerance:
|
|
95
|
+
best_quality = mid
|
|
96
|
+
break
|
|
97
|
+
|
|
98
|
+
if size > target_size:
|
|
99
|
+
high = mid - 1
|
|
100
|
+
else:
|
|
101
|
+
low = mid + 1
|
|
102
|
+
|
|
103
|
+
return best_quality
|
pixopt/cli/__init__.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""CLI package entry-point."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pixopt.cli.app import app
|
|
6
|
+
from pixopt.cli.commands import (
|
|
7
|
+
batch, # noqa: F401
|
|
8
|
+
compare, # noqa: F401
|
|
9
|
+
convert, # noqa: F401
|
|
10
|
+
favicon, # noqa: F401
|
|
11
|
+
info, # noqa: F401
|
|
12
|
+
optimize, # noqa: F401
|
|
13
|
+
placeholder, # noqa: F401
|
|
14
|
+
srcset, # noqa: F401
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
__all__ = ["app"]
|
pixopt/cli/app.py
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""Typer application and Rich console instances."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import typer
|
|
6
|
+
from rich.console import Console
|
|
7
|
+
|
|
8
|
+
app = typer.Typer(
|
|
9
|
+
name="pixopt",
|
|
10
|
+
help="Optimize images for web and storage.",
|
|
11
|
+
no_args_is_help=True,
|
|
12
|
+
rich_markup_mode="rich",
|
|
13
|
+
)
|
|
14
|
+
console = Console()
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""Import all CLI commands so they register with Typer."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pixopt.cli.commands.batch import batch
|
|
6
|
+
from pixopt.cli.commands.compare import compare
|
|
7
|
+
from pixopt.cli.commands.convert import convert
|
|
8
|
+
from pixopt.cli.commands.favicon import favicon
|
|
9
|
+
from pixopt.cli.commands.info import info
|
|
10
|
+
from pixopt.cli.commands.optimize import optimize
|
|
11
|
+
from pixopt.cli.commands.placeholder import placeholder
|
|
12
|
+
from pixopt.cli.commands.srcset import srcset
|
|
13
|
+
|
|
14
|
+
__all__ = ["optimize", "batch", "convert", "favicon", "info", "compare", "srcset", "placeholder"]
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Annotated
|
|
5
|
+
|
|
6
|
+
import typer
|
|
7
|
+
from rich.progress import BarColumn, Progress, SpinnerColumn, TaskProgressColumn, TextColumn
|
|
8
|
+
|
|
9
|
+
from pixopt.cli.app import app, console
|
|
10
|
+
from pixopt.cli.options import (
|
|
11
|
+
BackupOption,
|
|
12
|
+
FormatChoices,
|
|
13
|
+
HeightOption,
|
|
14
|
+
LosslessOption,
|
|
15
|
+
MinSizeOption,
|
|
16
|
+
OptimizeOption,
|
|
17
|
+
ProgressiveOption,
|
|
18
|
+
QualityOption,
|
|
19
|
+
StripOption,
|
|
20
|
+
WidthOption,
|
|
21
|
+
)
|
|
22
|
+
from pixopt.cli.output import _print_summary
|
|
23
|
+
from pixopt.models import OutputFormat
|
|
24
|
+
from pixopt.optimizer import optimize_image
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@app.command()
|
|
28
|
+
def batch(
|
|
29
|
+
sources: Annotated[
|
|
30
|
+
list[Path],
|
|
31
|
+
typer.Argument(
|
|
32
|
+
help="Source image files.",
|
|
33
|
+
exists=True,
|
|
34
|
+
resolve_path=True,
|
|
35
|
+
),
|
|
36
|
+
],
|
|
37
|
+
output_dir: Annotated[
|
|
38
|
+
Path,
|
|
39
|
+
typer.Option(
|
|
40
|
+
"--output-dir",
|
|
41
|
+
"-o",
|
|
42
|
+
help="Output directory for all processed images.",
|
|
43
|
+
),
|
|
44
|
+
] = Path("./optimized"),
|
|
45
|
+
width: WidthOption = None,
|
|
46
|
+
height: HeightOption = None,
|
|
47
|
+
quality: QualityOption = 85,
|
|
48
|
+
fmt: FormatChoices = OutputFormat.AUTO,
|
|
49
|
+
strip: StripOption = True,
|
|
50
|
+
progressive: ProgressiveOption = True,
|
|
51
|
+
optimize_flag: OptimizeOption = True,
|
|
52
|
+
lossless: LosslessOption = False,
|
|
53
|
+
backup: BackupOption = None,
|
|
54
|
+
min_size: MinSizeOption = None,
|
|
55
|
+
) -> None:
|
|
56
|
+
"""Optimize multiple image files at once."""
|
|
57
|
+
output_dir = output_dir.resolve()
|
|
58
|
+
output_dir.mkdir(parents=True, exist_ok=True)
|
|
59
|
+
|
|
60
|
+
min_bytes = min_size * 1024 if min_size is not None else None
|
|
61
|
+
|
|
62
|
+
results: list[object] = []
|
|
63
|
+
with Progress(
|
|
64
|
+
SpinnerColumn(),
|
|
65
|
+
TextColumn("[progress.description]{task.description}"),
|
|
66
|
+
BarColumn(),
|
|
67
|
+
TaskProgressColumn(),
|
|
68
|
+
console=console,
|
|
69
|
+
) as progress:
|
|
70
|
+
task = progress.add_task("Optimizing images...", total=len(sources))
|
|
71
|
+
for src in sources:
|
|
72
|
+
out = output_dir / src.name
|
|
73
|
+
result = optimize_image(
|
|
74
|
+
src,
|
|
75
|
+
out,
|
|
76
|
+
max_width=width,
|
|
77
|
+
max_height=height,
|
|
78
|
+
quality=quality,
|
|
79
|
+
strip_metadata=strip,
|
|
80
|
+
output_format=fmt,
|
|
81
|
+
progressive=progressive,
|
|
82
|
+
optimize=optimize_flag,
|
|
83
|
+
lossless=lossless,
|
|
84
|
+
backup_dir=backup,
|
|
85
|
+
min_size_bytes=min_bytes,
|
|
86
|
+
)
|
|
87
|
+
results.append(result)
|
|
88
|
+
progress.advance(task)
|
|
89
|
+
|
|
90
|
+
_print_summary(results) # type: ignore[arg-type]
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""compare command — generate an interactive HTML before/after slider."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Annotated
|
|
7
|
+
|
|
8
|
+
import typer
|
|
9
|
+
|
|
10
|
+
from pixopt.cli.app import app, console
|
|
11
|
+
from pixopt.cli.options import (
|
|
12
|
+
FormatChoices,
|
|
13
|
+
HeightOption,
|
|
14
|
+
LosslessOption,
|
|
15
|
+
OptimizeOption,
|
|
16
|
+
ProgressiveOption,
|
|
17
|
+
QualityOption,
|
|
18
|
+
StripOption,
|
|
19
|
+
WidthOption,
|
|
20
|
+
)
|
|
21
|
+
from pixopt.html_comparison import generate_comparison_html
|
|
22
|
+
from pixopt.models import OutputFormat
|
|
23
|
+
from pixopt.optimizer import optimize_image
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@app.command()
|
|
27
|
+
def compare(
|
|
28
|
+
source: Annotated[
|
|
29
|
+
Path,
|
|
30
|
+
typer.Argument(
|
|
31
|
+
help="Source image file.",
|
|
32
|
+
exists=True,
|
|
33
|
+
resolve_path=True,
|
|
34
|
+
),
|
|
35
|
+
],
|
|
36
|
+
output_html: Annotated[
|
|
37
|
+
Path,
|
|
38
|
+
typer.Argument(
|
|
39
|
+
help="Output HTML comparison file.",
|
|
40
|
+
exists=False,
|
|
41
|
+
),
|
|
42
|
+
] = Path("comparison.html"),
|
|
43
|
+
width: WidthOption = None,
|
|
44
|
+
height: HeightOption = None,
|
|
45
|
+
quality: QualityOption = 85,
|
|
46
|
+
fmt: FormatChoices = OutputFormat.AUTO,
|
|
47
|
+
strip: StripOption = True,
|
|
48
|
+
progressive: ProgressiveOption = True,
|
|
49
|
+
optimize_flag: OptimizeOption = True,
|
|
50
|
+
lossless: LosslessOption = False,
|
|
51
|
+
open_browser: Annotated[
|
|
52
|
+
bool,
|
|
53
|
+
typer.Option(
|
|
54
|
+
"--open/--no-open",
|
|
55
|
+
help="Open the generated HTML in the default browser.",
|
|
56
|
+
),
|
|
57
|
+
] = False,
|
|
58
|
+
) -> None:
|
|
59
|
+
"""Optimize an image and generate an interactive before/after comparison HTML."""
|
|
60
|
+
import tempfile
|
|
61
|
+
import webbrowser
|
|
62
|
+
|
|
63
|
+
output_html = output_html.resolve()
|
|
64
|
+
|
|
65
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
66
|
+
tmp_path = Path(tmp)
|
|
67
|
+
optimized = tmp_path / f"opt{source.suffix}"
|
|
68
|
+
result = optimize_image(
|
|
69
|
+
source,
|
|
70
|
+
optimized,
|
|
71
|
+
max_width=width,
|
|
72
|
+
max_height=height,
|
|
73
|
+
quality=quality,
|
|
74
|
+
strip_metadata=strip,
|
|
75
|
+
output_format=fmt,
|
|
76
|
+
progressive=progressive,
|
|
77
|
+
optimize=optimize_flag,
|
|
78
|
+
lossless=lossless,
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
if not result.success:
|
|
82
|
+
console.print(f"[bold red]Optimization failed:[/bold red] {result.error}")
|
|
83
|
+
raise typer.Exit(1)
|
|
84
|
+
|
|
85
|
+
generate_comparison_html(
|
|
86
|
+
before_path=source,
|
|
87
|
+
after_path=optimized,
|
|
88
|
+
output_html=output_html,
|
|
89
|
+
title=f"Comparison — {source.name}",
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
console.print(f"[bold green]Comparison saved to[/bold green] {output_html}")
|
|
93
|
+
if open_browser:
|
|
94
|
+
webbrowser.open(f"file://{output_html}")
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Annotated
|
|
5
|
+
|
|
6
|
+
import typer
|
|
7
|
+
|
|
8
|
+
from pixopt.cli.app import app, console
|
|
9
|
+
from pixopt.cli.options import (
|
|
10
|
+
BackupOption,
|
|
11
|
+
FormatChoices,
|
|
12
|
+
HeightOption,
|
|
13
|
+
LosslessOption,
|
|
14
|
+
MinSizeOption,
|
|
15
|
+
OptimizeOption,
|
|
16
|
+
OverwriteOption,
|
|
17
|
+
ProgressiveOption,
|
|
18
|
+
QualityOption,
|
|
19
|
+
RecursiveOption,
|
|
20
|
+
StripOption,
|
|
21
|
+
WidthOption,
|
|
22
|
+
)
|
|
23
|
+
from pixopt.cli.output import _print_result, _print_summary
|
|
24
|
+
from pixopt.models import OutputFormat
|
|
25
|
+
from pixopt.optimizer import change_extension, optimize_directory
|
|
26
|
+
from pixopt.smart_format import detect_optimal_format
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@app.command()
|
|
30
|
+
def convert(
|
|
31
|
+
source: Annotated[
|
|
32
|
+
Path,
|
|
33
|
+
typer.Argument(
|
|
34
|
+
help="Source image file or directory.",
|
|
35
|
+
exists=True,
|
|
36
|
+
resolve_path=True,
|
|
37
|
+
),
|
|
38
|
+
],
|
|
39
|
+
output: Annotated[
|
|
40
|
+
Path | None,
|
|
41
|
+
typer.Argument(
|
|
42
|
+
help="Output path (file or directory).",
|
|
43
|
+
exists=False,
|
|
44
|
+
),
|
|
45
|
+
] = None,
|
|
46
|
+
width: WidthOption = None,
|
|
47
|
+
height: HeightOption = None,
|
|
48
|
+
quality: QualityOption = 85,
|
|
49
|
+
fmt: FormatChoices = OutputFormat.AUTO,
|
|
50
|
+
strip: StripOption = True,
|
|
51
|
+
progressive: ProgressiveOption = True,
|
|
52
|
+
optimize_flag: OptimizeOption = True,
|
|
53
|
+
recursive: RecursiveOption = False,
|
|
54
|
+
overwrite: OverwriteOption = False,
|
|
55
|
+
lossless: LosslessOption = False,
|
|
56
|
+
backup: BackupOption = None,
|
|
57
|
+
min_size: MinSizeOption = None,
|
|
58
|
+
smart_format: Annotated[
|
|
59
|
+
bool,
|
|
60
|
+
typer.Option(
|
|
61
|
+
"--smart-format",
|
|
62
|
+
help="Auto-detect the most efficient output format.",
|
|
63
|
+
),
|
|
64
|
+
] = False,
|
|
65
|
+
) -> None:
|
|
66
|
+
"""Convert image(s) to a different format or extension."""
|
|
67
|
+
resolved_fmt = fmt
|
|
68
|
+
if smart_format and not source.is_dir():
|
|
69
|
+
detected = detect_optimal_format(source)
|
|
70
|
+
resolved_fmt = detected
|
|
71
|
+
console.print(f"[dim]Smart format detected: {detected.value}[/dim]")
|
|
72
|
+
|
|
73
|
+
min_bytes = min_size * 1024 if min_size is not None else None
|
|
74
|
+
|
|
75
|
+
if source.is_dir():
|
|
76
|
+
if output is not None:
|
|
77
|
+
output = output.resolve()
|
|
78
|
+
results = optimize_directory(
|
|
79
|
+
source,
|
|
80
|
+
output,
|
|
81
|
+
recursive=recursive,
|
|
82
|
+
max_width=width,
|
|
83
|
+
max_height=height,
|
|
84
|
+
quality=quality,
|
|
85
|
+
strip_metadata=strip,
|
|
86
|
+
output_format=resolved_fmt,
|
|
87
|
+
progressive=progressive,
|
|
88
|
+
optimize=optimize_flag,
|
|
89
|
+
lossless=lossless,
|
|
90
|
+
backup_dir=backup,
|
|
91
|
+
min_size_bytes=min_bytes,
|
|
92
|
+
)
|
|
93
|
+
_print_summary(results)
|
|
94
|
+
else:
|
|
95
|
+
result = change_extension(
|
|
96
|
+
source,
|
|
97
|
+
output,
|
|
98
|
+
max_width=width,
|
|
99
|
+
max_height=height,
|
|
100
|
+
quality=quality,
|
|
101
|
+
strip_metadata=strip,
|
|
102
|
+
output_format=resolved_fmt,
|
|
103
|
+
progressive=progressive,
|
|
104
|
+
optimize=optimize_flag,
|
|
105
|
+
overwrite=overwrite,
|
|
106
|
+
lossless=lossless,
|
|
107
|
+
backup_dir=backup,
|
|
108
|
+
min_size_bytes=min_bytes,
|
|
109
|
+
)
|
|
110
|
+
_print_result(result)
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Annotated
|
|
5
|
+
|
|
6
|
+
import typer
|
|
7
|
+
|
|
8
|
+
from pixopt.cli.app import app
|
|
9
|
+
from pixopt.cli.output import _print_result
|
|
10
|
+
from pixopt.optimizer import convert_to_favicon
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@app.command()
|
|
14
|
+
def favicon(
|
|
15
|
+
source: Annotated[
|
|
16
|
+
Path,
|
|
17
|
+
typer.Argument(
|
|
18
|
+
help="Source image file.",
|
|
19
|
+
exists=True,
|
|
20
|
+
resolve_path=True,
|
|
21
|
+
),
|
|
22
|
+
],
|
|
23
|
+
output: Annotated[
|
|
24
|
+
Path | None,
|
|
25
|
+
typer.Argument(
|
|
26
|
+
help="Output .ico path. Defaults to source name with .ico.",
|
|
27
|
+
exists=False,
|
|
28
|
+
),
|
|
29
|
+
] = None,
|
|
30
|
+
sizes: Annotated[
|
|
31
|
+
list[int] | None,
|
|
32
|
+
typer.Option(
|
|
33
|
+
"--size",
|
|
34
|
+
help="Square sizes to include in the ICO.",
|
|
35
|
+
),
|
|
36
|
+
] = None,
|
|
37
|
+
keep_transparency: Annotated[
|
|
38
|
+
bool,
|
|
39
|
+
typer.Option(
|
|
40
|
+
"--keep-transparency/--fill-background",
|
|
41
|
+
help="Preserve alpha channel or fill with background color.",
|
|
42
|
+
),
|
|
43
|
+
] = True,
|
|
44
|
+
) -> None:
|
|
45
|
+
"""Convert an image to a multi-resolution favicon (.ico)."""
|
|
46
|
+
result = convert_to_favicon(
|
|
47
|
+
source,
|
|
48
|
+
output,
|
|
49
|
+
sizes=sizes if sizes is not None else [16, 32, 48, 64, 128, 256],
|
|
50
|
+
keep_transparency=keep_transparency,
|
|
51
|
+
)
|
|
52
|
+
_print_result(result)
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Annotated
|
|
5
|
+
|
|
6
|
+
import typer
|
|
7
|
+
from PIL import Image
|
|
8
|
+
from PIL.ExifTags import Base
|
|
9
|
+
|
|
10
|
+
from pixopt.cli.app import app, console
|
|
11
|
+
from pixopt.cli.output import _human_size
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@app.command()
|
|
15
|
+
def info(
|
|
16
|
+
source: Annotated[
|
|
17
|
+
Path,
|
|
18
|
+
typer.Argument(
|
|
19
|
+
help="Image file to inspect.",
|
|
20
|
+
exists=True,
|
|
21
|
+
resolve_path=True,
|
|
22
|
+
),
|
|
23
|
+
],
|
|
24
|
+
) -> None:
|
|
25
|
+
"""Show image metadata and properties without optimizing."""
|
|
26
|
+
with Image.open(source) as img:
|
|
27
|
+
console.print(f"[bold cyan]File:[/bold cyan] {source}")
|
|
28
|
+
console.print(f"[bold cyan]Size:[/bold cyan] {img.size[0]}x{img.size[1]} px")
|
|
29
|
+
console.print(f"[bold cyan]Mode:[/bold cyan] {img.mode}")
|
|
30
|
+
console.print(f"[bold cyan]Format:[/bold cyan] {img.format}")
|
|
31
|
+
console.print(f"[bold cyan]File size:[/bold cyan] {_human_size(source.stat().st_size)}")
|
|
32
|
+
|
|
33
|
+
if img.format == "JPEG":
|
|
34
|
+
prog = "Yes" if img.info.get("progressive") else "No"
|
|
35
|
+
console.print(f"[bold cyan]Progressive:[/bold cyan] {prog}")
|
|
36
|
+
|
|
37
|
+
exif = img.getexif()
|
|
38
|
+
if exif:
|
|
39
|
+
console.print("\n[bold yellow]EXIF Metadata:[/bold yellow]")
|
|
40
|
+
for tag_id, value in exif.items():
|
|
41
|
+
tag = Base(tag_id).name if tag_id in {t.value for t in Base} else f"Tag {tag_id}"
|
|
42
|
+
console.print(f" {tag}: {value}")
|
|
43
|
+
else:
|
|
44
|
+
console.print("\n[bold yellow]EXIF Metadata:[/bold yellow] None")
|