2424
2525logger = logging .getLogger (__name__ )
2626
27+ # Responsive image sizes and formats (issue #5191)
28+ RESPONSIVE_SIZES = [1200 , 800 , 400 ]
29+ RESPONSIVE_FORMATS : list [tuple [str , str , dict ]] = [
30+ ("png" , "PNG" , {}),
31+ ("webp" , "WEBP" , {"quality" : 80 }),
32+ ]
33+ WEBP_FULL_QUALITY = 85
34+
2735# GCS bucket for static assets (fonts)
2836GCS_STATIC_BUCKET = "pyplots-static"
2937MONOLISA_FONT_PATH = "fonts/MonoLisaVariableNormal.ttf"
@@ -205,6 +213,81 @@ def process_plot_image(
205213 return result
206214
207215
216+ def create_responsive_variants (
217+ input_path : str | Path ,
218+ output_dir : str | Path ,
219+ sizes : list [int ] | None = None ,
220+ optimize : bool = True ,
221+ ) -> list [dict [str , str | int ]]:
222+ """Generate multi-size, multi-format image variants for responsive delivery.
223+
224+ Creates sized PNGs and WebPs (400/800/1200) plus a full-size WebP from the
225+ source image. File naming follows the convention expected by the frontend:
226+ plot_1200.png, plot_1200.webp, plot_800.png, plot_800.webp,
227+ plot_400.png, plot_400.webp, plot.webp
228+
229+ Args:
230+ input_path: Path to the source plot image (plot.png).
231+ output_dir: Directory where variants will be written.
232+ sizes: Override default RESPONSIVE_SIZES if needed.
233+ optimize: Whether to optimize PNGs with pngquant.
234+
235+ Returns:
236+ List of dicts, each with 'path', 'width', 'height', 'format'.
237+ """
238+ input_path = Path (input_path )
239+ output_dir = Path (output_dir )
240+ output_dir .mkdir (parents = True , exist_ok = True )
241+
242+ img = Image .open (input_path )
243+ if img .mode in ("RGBA" , "P" ):
244+ img = img .convert ("RGB" )
245+
246+ results : list [dict [str , str | int ]] = []
247+ target_sizes = sizes or RESPONSIVE_SIZES
248+
249+ # Sized variants (e.g. plot_1200.png, plot_1200.webp, plot_800.png, ...)
250+ for width in target_sizes :
251+ # Skip sizes larger than the original
252+ if width >= img .width :
253+ resized = img
254+ actual_width , actual_height = img .width , img .height
255+ else :
256+ ratio = width / img .width
257+ actual_width = width
258+ actual_height = int (img .height * ratio )
259+ resized = img .resize ((actual_width , actual_height ), Image .Resampling .LANCZOS )
260+
261+ for ext , fmt , opts in RESPONSIVE_FORMATS :
262+ out_path = output_dir / f"plot_{ width } .{ ext } "
263+ resized .save (out_path , fmt , optimize = True , ** opts )
264+
265+ # Optimize PNG with pngquant
266+ if optimize and fmt == "PNG" :
267+ optimize_png (out_path )
268+
269+ results .append ({
270+ "path" : str (out_path ),
271+ "width" : actual_width ,
272+ "height" : actual_height ,
273+ "format" : ext ,
274+ })
275+ logger .info ("Created %s (%dx%d)" , out_path .name , actual_width , actual_height )
276+
277+ # Full-size WebP
278+ webp_path = output_dir / "plot.webp"
279+ img .save (webp_path , "WEBP" , quality = WEBP_FULL_QUALITY )
280+ results .append ({
281+ "path" : str (webp_path ),
282+ "width" : img .width ,
283+ "height" : img .height ,
284+ "format" : "webp" ,
285+ })
286+ logger .info ("Created plot.webp (%dx%d)" , img .width , img .height )
287+
288+ return results
289+
290+
208291# =============================================================================
209292# Before/After Comparison Images
210293# =============================================================================
@@ -758,13 +841,15 @@ def print_usage() -> None:
758841 print ("Usage:" )
759842 print (" python -m core.images thumbnail <input> <output> [width]" )
760843 print (" python -m core.images process <input> <output> [thumb]" )
844+ print (" python -m core.images responsive <input> <output_dir>" )
761845 print (" python -m core.images brand <input> <output> [spec_id] [library]" )
762846 print (" python -m core.images collage <output> <img1> [img2] [img3] [img4]" )
763847 print (" python -m core.images compare <before> <after> <output> [spec_id] [library]" )
764848 print ("" )
765849 print ("Examples:" )
766850 print (" python -m core.images thumbnail plot.png thumb.png 400" )
767851 print (" python -m core.images process plot.png out.png thumb.png" )
852+ print (" python -m core.images responsive plot.png ./output/" )
768853 print (" python -m core.images brand plot.png og.png scatter-basic matplotlib" )
769854 print (" python -m core.images collage og.png img1.png img2.png img3.png img4.png" )
770855 print (" python -m core.images compare before.png after.png comparison.png area-basic matplotlib" )
@@ -791,6 +876,15 @@ def print_usage() -> None:
791876 res = process_plot_image (input_file , output_file , thumb_file )
792877 print (f"Processed: { res } " )
793878
879+ elif command == "responsive" :
880+ if len (sys .argv ) < 4 :
881+ print_usage ()
882+ input_file , output_dir = sys .argv [2 ], sys .argv [3 ]
883+ variants = create_responsive_variants (input_file , output_dir )
884+ print (f"Created { len (variants )} responsive variants in { output_dir } :" )
885+ for v in variants :
886+ print (f" { Path (v ['path' ]).name } : { v ['width' ]} x{ v ['height' ]} ({ v ['format' ]} )" )
887+
794888 elif command == "brand" :
795889 if len (sys .argv ) < 4 :
796890 print_usage ()
0 commit comments