11"""Module for shrink images."""
22
3+ import logging
34import pathlib
45from math import floor , sqrt
56from time import time
6- from typing import Any , Literal , Optional
7+ from typing import Any , Optional
78
89from PIL import Image
910from tqdm import tqdm
1011
1112from .utils import (
1213 AllImageSource ,
14+ Formats ,
1315 PathLike ,
1416 PathOrFile ,
1517 open_image ,
1618 ratio ,
1719 size ,
20+ verify_format ,
1821)
1922
2023MAX_COLORS = 256
2124MAX_SAMPLE = 100_00
2225
26+ logger = logging .getLogger (__name__ )
27+
2328
2429class Shrinkix :
2530 def __init__ ( # noqa: PLR0913
2631 self ,
2732 * ,
28- format : Optional [Literal ["PNG" , "JPEG" , "JPG" , "WEBP" ]] = None , # noqa: A002
2933 keep_metadata : Optional [bool ] = None ,
3034 max_width : Optional [int ] = None ,
3135 max_height : Optional [int ] = None ,
@@ -46,20 +50,26 @@ def __init__( # noqa: PLR0913
4650 self .artist = artist
4751 self .background = background
4852 self .quality = int (quality ) if quality is not None else None
49- if format is None :
50- self .format = "PNG"
51- elif format .casefold () == "jpg" :
52- self .format = "JPEG"
53- else :
54- self .format = format .upper ()
5553
56- def shrink ( # noqa: PLR0912, C901
54+ def shrink ( # noqa: PLR0912, C901, PLR0915
5755 self ,
5856 image : AllImageSource ,
5957 output : PathOrFile ,
58+ format : Optional [Formats ] = None , # noqa: A002
6059 colors : Optional [int ] = None ,
6160 ) -> None :
6261 """Shrink an image."""
62+ # Get the output format
63+ if format is None :
64+ if isinstance (output , (str , pathlib .Path )):
65+ format = verify_format (pathlib .Path (output ).suffix ) # noqa: A001
66+ else :
67+ msg = (
68+ "Cannot infer the format from the output; "
69+ "please specify the format parameter."
70+ )
71+ raise ValueError (msg )
72+
6373 # Load image
6474 im = open_image (image )
6575
@@ -88,9 +98,14 @@ def shrink( # noqa: PLR0912, C901
8898 im = Image .new ("RGBA" , im .size )
8999 im .putdata (data )
90100
91- # Reduce colors
92- im = self .reduce (im , colors = colors )
93- options : dict [str , Any ] = {"format" : self .format }
101+ # Reduce colors (Palette is not supported on JPEG or WEBP)
102+ if format not in ("JPEG" , "WEBP" ):
103+ im = self .reduce (im , colors = colors )
104+ else :
105+ im = im .convert ("RGB" ) if im .mode != "RGB" else im
106+
107+ # Specify format in options
108+ options : dict [str , Any ] = {"format" : format }
94109
95110 # Add exif information
96111 import piexif
@@ -106,16 +121,17 @@ def shrink( # noqa: PLR0912, C901
106121 # Save with optimization
107122 if self .quality is None :
108123 quality = floor (30 + 65 * (1 - min (sqrt (w * h ) / 4096 , 1 )))
124+ logger .info ("Resolved quality is %s" , quality )
109125 else :
110126 quality = self .quality
111127 # https://pillow.readthedocs.io/en/stable/handbook/image-file-formats.html#png
112- if self . format == "PNG" :
128+ if format == "PNG" :
113129 options ["optimize" ] = True
114130 # https://pillow.readthedocs.io/en/stable/handbook/image-file-formats.html#jpeg
115- elif self . format == "JPEG" :
131+ elif format == "JPEG" :
116132 options ["quality" ] = quality
117133 # https://pillow.readthedocs.io/en/stable/handbook/image-file-formats.html#webp
118- elif self . format == "WEBP" :
134+ elif format == "WEBP" :
119135 options ["lossless" ] = False
120136 options ["quality" ] = quality
121137 options ["alpha_quality" ] = quality
@@ -127,29 +143,50 @@ def shrink( # noqa: PLR0912, C901
127143 else :
128144 im .save (output , ** options )
129145
130- def export_name (self , path : pathlib .Path ) -> str :
146+ def export_name (self , path : pathlib .Path , format : Optional [ str ] ) -> str : # noqa: A002
131147 """Export name."""
132- return path .with_suffix (f".{ self .format .lower ()} " ).name
148+ if format :
149+ return path .with_suffix (f".{ format .lower ()} " ).name
150+ return path .name
133151
134- def bulk (
152+ def bulk ( # noqa: PLR0912
135153 self ,
136154 files : list [PathLike ],
137- output : PathLike ,
155+ output : Optional [PathLike ],
156+ inplace : Optional [bool ] = None ,
157+ format : Optional [Formats ] = None , # noqa: A002
138158 colors : Optional [int ] = None ,
139159 ) -> None :
140160 """Shrink a list of file and export it in output."""
141- root = pathlib .Path (output )
142- paths = {}
161+ if inplace is None :
162+ inplace = False
163+ if format :
164+ format = verify_format (format ) # noqa: A001
165+
166+ if inplace :
167+ if output is not None :
168+ error_message = '"output" and "inplace" are mutually exclusive'
169+ raise ValueError (error_message )
170+ elif output is None :
171+ error_message = 'You should provide at least "output" or "inplace"'
172+ raise ValueError (error_message )
173+ else :
174+ output = pathlib .Path (output )
175+
176+ paths : dict [pathlib .Path , pathlib .Path ] = {}
143177 for file in files :
144178 path = pathlib .Path (file )
145179 if path .is_dir ():
146- for sub_path in path .glob ("**/*" ):
180+ source_paths = list (path .glob ("**/*" ))
181+ for sub_path in source_paths :
147182 if sub_path .is_file ():
148- output_path = root / self .export_name (sub_path )
149- paths [sub_path ] = output_path
183+ name = self .export_name (sub_path , format )
184+ parent = sub_path .parent if inplace else output
185+ paths [sub_path ] = parent / name # type: ignore[operator]
150186 else :
151- output_path = root / self .export_name (path )
152- paths [path ] = output_path
187+ name = self .export_name (path , format )
188+ parent = path .parent if inplace else output
189+ paths [path ] = parent / name # type: ignore[operator]
153190
154191 with tqdm (paths .items ()) as bar :
155192 for src , dst in bar :
@@ -163,6 +200,8 @@ def bulk(
163200 f"ratio: { ratio (src , dst ):.2%} , time: { elapsed :.3f} s, "
164201 f"size: { size (src )} to { size (dst )} " ,
165202 )
203+ if inplace and src .resolve () != dst .resolve ():
204+ src .unlink (missing_ok = True )
166205
167206 def reduce (
168207 self ,
@@ -173,10 +212,6 @@ def reduce(
173212 # Open and format for model
174213 im = open_image (image )
175214
176- # Palette is not supported on JPEG or WEBP
177- if self .format in ("JPEG" , "WEBP" ):
178- return im .convert ("RGB" ) if im .mode != "RGB" else im
179-
180215 # No optimization on palette or black and white
181216 if im .mode in ("L" , "LA" , "P" , "PA" ):
182217 return im
@@ -210,6 +245,7 @@ def reduce(
210245 )
211246 colors = len (block_counts )
212247 colors = min (colors , MAX_COLORS )
248+ logger .info ("Use %s colors" , colors )
213249
214250 if not self .experimental_color_reduction :
215251 return im .quantize (colors )
0 commit comments