# Copyright (c) 2016-2025 Martin Donath # Permission is hereby granted, free of charge, to any person obtaining a copy # of this software and associated documentation files (the "Software"), to # deal in the Software without restriction, including without limitation the # rights to use, copy, modify, merge, publish, distribute, sublicense, and/or # sell copies of the Software, and to permit persons to whom the Software is # furnished to do so, subject to the following conditions: # The above copyright notice and this permission notice shall be included in # all copies or substantial portions of the Software. # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, # FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING # FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS # IN THE SOFTWARE. from __future__ import annotations import functools import json import logging import os import subprocess import sys from fnmatch import fnmatch from colorama import Fore, Style from concurrent.futures import Future from concurrent.futures.thread import ThreadPoolExecutor from hashlib import sha1 from mkdocs import utils from mkdocs.config.defaults import MkDocsConfig from mkdocs.exceptions import PluginError from mkdocs.plugins import BasePlugin from mkdocs.structure.files import File from shutil import which try: from PIL import Image except ImportError: pass from .config import OptimizeConfig # ----------------------------------------------------------------------------- # Classes # ----------------------------------------------------------------------------- # Optimize plugin class OptimizePlugin(BasePlugin[OptimizeConfig]): supports_multiple_instances = True # Manifest manifest: dict[str, str] = {} # Initialize plugin def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) # Initialize incremental builds self.is_serve = False # Determine whether we're serving the site def on_startup(self, *, command, dirty): self.is_serve = command == "serve" # Initialize thread pool self.pool = ThreadPoolExecutor(self.config.concurrency) self.pool_jobs: dict[str, Future] = {} # Resolve and load manifest def on_config(self, config): if not self.config.enabled: return # Resolve cache directory (once) - this is necessary, so the cache is # always relative to the configuration file, and thus project, and not # relative to the current working directory, or it would not work with # the projects plugin. path = os.path.abspath(self.config.cache_dir) if path != self.config.cache_dir: self.config.cache_dir = os.path.join( os.path.dirname(config.config_file_path), os.path.normpath(self.config.cache_dir) ) # Ensure cache directory exists os.makedirs(self.config.cache_dir, exist_ok = True) # Initialize manifest self.manifest_file = os.path.join( self.config.cache_dir, "manifest.json" ) # Load manifest if it exists and the cache should be used if os.path.isfile(self.manifest_file) and self.config.cache: try: with open(self.manifest_file) as f: self.manifest = json.load(f) except: pass # Initialize optimization pipeline def on_env(self, env, *, config, files): if not self.config.enabled: return # Skip if media files should not be optimized if not self.config.optimize: return # Filter all optimizable media files and steal reponsibility from MkDocs # by removing them from the files collection. Then, start a concurrent # job that checks if an image was already optimized and can be returned # from the cache, or optimize it accordingly. for file in files.media_files(): if self._is_excluded(file): continue # Spawn concurrent job to optimize the given image and add future # to job dictionary, as it returns the file we need to copy later path = os.path.join(self.config.cache_dir, file.src_path) self.pool_jobs[file.abs_src_path] = self.pool.submit( self._optimize_image, file, path, config ) # Steal responsibility from MkDocs files.remove(file) # Finish optimization pipeline def on_post_build(self, *, config): if not self.config.enabled: return # Skip if media files should not be optimized if not self.config.optimize: return # Reconcile concurrent jobs - we need to wait for all jobs to finish # before we can copy the optimized files to the output directory. If an # exception occurred in one of the jobs, we raise it here, so the build # fails and the author can fix the issue. for path, future in self.pool_jobs.items(): if future.exception(): raise future.exception() else: file: File = future.result() file.copy_file() # Save manifest if cache should be used if self.config.cache: with open(self.manifest_file, "w") as f: f.write(json.dumps(self.manifest, indent = 2, sort_keys = True)) # Compute and print gains through optimization if self.config.print_gain_summary: print(Style.NORMAL) print(f" Optimizations:") # Print summary for file extension for seek in [".png", ".jpg"]: size = size_opt = 0 for path, future in self.pool_jobs.items(): file: File = future.result() # Skip files that are not of the given type _, extension = os.path.splitext(path) extension = ".jpg" if extension == ".jpeg" else extension if extension != seek: continue # Compute size before and after optimization size += os.path.getsize(path) size_opt += os.path.getsize(file.abs_dest_path) # Compute absolute and relative gain if size and size_opt: gain_abs = size - size_opt gain_rel = (1 - size_opt / size) * 100 # Print summary for files print( f" *{seek} {Fore.GREEN}{_size(size_opt)}" f"{Fore.WHITE}{Style.DIM} ↓ " f"{_size(gain_abs)} [{gain_rel:3.1f}%]" f"{Style.RESET_ALL}" ) # Reset all styles print(Style.RESET_ALL) # Save manifest on shutdown def on_shutdown(self): if not self.config.enabled: return # Shutdown thread pool - if we're on Python 3.9 and above, cancel all # pending futures that have not yet been scheduled if sys.version_info >= (3, 9): self.pool.shutdown(cancel_futures = True) else: self.pool.shutdown() # Save manifest if cache should be used if self.manifest and self.config.cache: with open(self.manifest_file, "w") as f: f.write(json.dumps(self.manifest, indent = 2, sort_keys = True)) # ------------------------------------------------------------------------- # Check if a file can be optimized def _is_optimizable(self, file: File): # Check if PNG images should be optimized if file.url.endswith((".png")): return self.config.optimize_png # Check if JPG images should be optimized if file.url.endswith((".jpg", ".jpeg")): return self.config.optimize_jpg # File can not be optimized by the plugin return False # Check if the given file is excluded def _is_excluded(self, file: File): if not self._is_optimizable(file): return True # Check if file matches one of the inclusion patterns path = file.src_path if self.config.optimize_include: for pattern in self.config.optimize_include: if fnmatch(file.src_uri, pattern): return False # File is not included log.debug(f"Excluding file '{path}' due to inclusion patterns") return True # Check if file matches one of the exclusion patterns for pattern in self.config.optimize_exclude: if fnmatch(file.src_uri, pattern): log.debug(f"Excluding file '{path}' due to exclusion patterns") return True # File is not excluded return False # Optimize image and write to cache def _optimize_image(self, file: File, path: str, config: MkDocsConfig): with open(file.abs_src_path, "rb") as f: data = f.read() hash = sha1(data).hexdigest() # Check if file hash changed, so we need to optimize again prev = self.manifest.get(file.url, "") if hash != prev or not os.path.isfile(path): os.makedirs(os.path.dirname(path), exist_ok = True) # Optimize PNG image using pngquant if file.url.endswith((".png")): self._optimize_image_png(file, path, config) # Optimize JPG image using pillow if file.url.endswith((".jpg", ".jpeg")): self._optimize_image_jpg(file, path, config) # Compute size before and after optimization size = len(data) size_opt = os.path.getsize(path) # Compute absolute and relative gain gain_abs = size - size_opt gain_rel = (1 - size_opt / size) * 100 # Print how much we gained, if we did and desired gain = "" if gain_abs and self.config.print_gain: gain += " ↓ " gain += " ".join([_size(gain_abs), f"[{gain_rel:3.1f}%]"]) # Print summary for file log.info( f"Optimized media file: {file.src_uri} " f"{Fore.GREEN}{_size(size_opt)}" f"{Fore.WHITE}{Style.DIM}{gain}" f"{Style.RESET_ALL}" ) # Update manifest by associating file with hash self.manifest[file.url] = hash # Compute project root root = os.path.dirname(config.config_file_path) # Compute source file system path file.abs_src_path = path file.src_path = os.path.relpath(path, root) # Return file to be copied from cache return file # Optimize PNG image - we first tried to use libimagequant, but encountered # the occassional segmentation fault, which means it's probably not a good # choice. Instead, we just rely on pngquant which seems much more stable. def _optimize_image_png(self, file: File, path: str, config: MkDocsConfig): # Check if the required dependencies for optimizing are available, which # is, at the absolute minimum, the 'pngquant' binary, and raise an error # to the caller, so he can decide what to do with the error. The caller # can treat this as a warning or an error to abort the build. if not which("pngquant"): docs = os.path.relpath(config.docs_dir) path = os.path.relpath(file.abs_src_path, docs) raise PluginError( f"Couldn't optimize image '{path}' in '{docs}': 'pngquant' " f"not found. Make sure 'pngquant' is installed and in your path" ) # Build command line arguments args = ["pngquant", "--force", "--skip-if-larger", "--output", path, "--speed", f"{self.config.optimize_png_speed}" ] # Add flag to remove optional metadata if self.config.optimize_png_strip: args.append("--strip") # Set input file and run, then check if pngquant actually wrote a file, # as we instruct it not to if the size of the optimized file is larger. # This can happen if files are already compressed and optimized by # the author. In that case, just copy the original file. subprocess.run([*args, file.abs_src_path]) if not os.path.isfile(path): utils.copy_file(file.abs_src_path, path) # Optimize JPG image def _optimize_image_jpg(self, file: File, path: str, config: MkDocsConfig): # Check if the required dependencies for optimizing are available, which # is, at the absolute minimum, the 'pillow' package, and raise an error # to the caller, so he can decide what to do with the error. The caller # can treat this as a warning or an error to abort the build. if not _supports("Image"): docs = os.path.relpath(config.docs_dir) path = os.path.relpath(file.abs_src_path, docs) raise PluginError( f"Couldn't optimize image '{path}' in '{docs}': install " f"required dependencies – pip install 'mkdocs-material[imaging]'" ) # Open and save optimized image image = Image.open(file.abs_src_path) image.save(path, "jpeg", quality = self.config.optimize_jpg_quality, progressive = self.config.optimize_jpg_progressive ) # ----------------------------------------------------------------------------- # Helper functions # ----------------------------------------------------------------------------- # Check for presence of optional imports @functools.lru_cache(maxsize = None) def _supports(name: str): return name in globals() # ----------------------------------------------------------------------------- # Print human-readable size def _size(value): for unit in ["B", "kB", "MB", "GB", "TB", "PB", "EB", "ZB"]: if abs(value) < 1000.0: return f"{value:3.1f} {unit}" value /= 1000.0 # ----------------------------------------------------------------------------- # Data # ----------------------------------------------------------------------------- # Set up logging log = logging.getLogger("mkdocs.material.optimize")