Files
mkdocs-material/material/plugins/optimize/plugin.py
2025-11-11 09:44:22 +01:00

389 lines
15 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Copyright (c) 2016-2025 Martin Donath <martin.donath@squidfunk.com>
# 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")