# Copyright Kevin Deldycke <[email protected]> and contributors.
#
# This program is Free Software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
"""Image optimization using external CLI tools.
Replaces the Docker-based `calibreapp/image-actions` GitHub Action with direct
invocations of lightweight CLI tools, removing the Docker dependency.
Tools used per format:
- PNG: `oxipng` (lossless, multithreaded Rust optimizer).
- JPEG/JPG: `jpegoptim` (lossless Huffman optimization + metadata stripping).
```{note}
Both tools are strictly **lossless**: `oxipng` finds optimal PNG encoding
parameters without altering pixel data, and `jpegoptim` (without `-m`)
rewrites Huffman tables only. This means optimization is **idempotent** β a
second run produces no further changes, so the workflow never creates noisy
PRs for negligible savings.
```
```{warning}
WebP and AVIF are intentionally **not** optimized. The only available tools
(`cwebp`, `avifenc`) work by lossy re-encoding: decode β re-compress at
a target quality. This is **not idempotent** β each pass re-compresses the
previous output, producing progressively smaller (and worse) files. The
earlier `calibreapp/image-actions` suffered from this: it required multiple
workflow runs to stabilize below the savings threshold, generating repeated
PRs with diminishing returns and cumulative quality loss. Lossless WebP/AVIF
modes exist but typically *increase* file size when applied to already
lossy-encoded images, making them counterproductive. Since WebP and AVIF are
modern formats chosen specifically for their compression efficiency, files in
these formats are almost always already well-optimized at creation time.
```
"""
from __future__ import annotations
import logging
import shutil
import subprocess
import tempfile
from dataclasses import dataclass
from pathlib import Path
from click_extra import TableFormat, render_table
from .humanize import format_file_size
from .tool_registry import TOOL_REGISTRY
from .tool_runner import ensure_binary
TYPE_CHECKING = False
if TYPE_CHECKING:
from collections.abc import Callable, Sequence
# Minimum percentage savings required to keep the optimized file.
DEFAULT_MIN_SAVINGS_PCT = 5
# Minimum absolute byte savings required to keep the optimized file.
DEFAULT_MIN_SAVINGS_BYTES = 1024
OXIPNG_OPT_LEVEL = "4"
JPEGOPTIM_FLAGS = ("--strip-all", "--all-progressive")
[docs]
@dataclass
class OptimizationResult:
"""Result of optimizing a single image file."""
path: Path
before_bytes: int
after_bytes: int
@property
def saved_bytes(self) -> int:
"""Bytes saved by optimization."""
return self.before_bytes - self.after_bytes
@property
def saved_pct(self) -> float:
"""Percentage saved, as a float 0β100."""
if self.before_bytes == 0:
return 0.0
return (self.saved_bytes / self.before_bytes) * 100
def _check_tool(name: str) -> bool:
"""Whether *name* can be run, from the registry or from `$PATH`.
A tool the registry ships as a binary is always obtainable, so it never
depends on what the machine happens to have installed:
{func}`~repomatic.tool_runner.ensure_binary` downloads and verifies it on
first use. A download that fails then raises rather than silently skipping
the file, which is the intent: a pinned tool that cannot be fetched is a
problem to surface, not to work around.
Everything else still has to be on `$PATH`. `jpegoptim` is the only such
tool today, because upstream publishes source tarballs rather than
prebuilt binaries, leaving the distro package as the sole way to get it.
"""
spec = TOOL_REGISTRY.get(name)
if spec is not None and spec.binary is not None:
return True
return shutil.which(name) is not None
def _optimize_png(path: Path) -> None:
"""Optimize a PNG file in-place with `oxipng`.
The binary comes from the `repomatic run` registry rather than `$PATH`, so
it is the pinned version with its archive checksum verified, identical on
every runner and every developer machine. `jpegoptim` below cannot follow:
upstream publishes source tarballs only, leaving the distro package as the
single way to get it.
"""
subprocess.run(
[
str(ensure_binary("oxipng")),
"--opt",
OXIPNG_OPT_LEVEL,
"--strip",
"safe",
str(path),
],
capture_output=True,
text=True,
encoding="UTF-8",
check=True,
)
def _optimize_jpeg(path: Path) -> None:
"""Optimize a JPEG file in-place with `jpegoptim`."""
subprocess.run(
["jpegoptim", *JPEGOPTIM_FLAGS, str(path)],
capture_output=True,
text=True,
encoding="UTF-8",
check=True,
)
# Map file extensions to their optimizer function and required tool name.
OPTIMIZERS: dict[str, tuple[str, Callable[[Path], None]]] = {
".png": ("oxipng", _optimize_png),
".jpg": ("jpegoptim", _optimize_jpeg),
".jpeg": ("jpegoptim", _optimize_jpeg),
}
[docs]
def optimize_image(
path: Path,
min_savings_pct: float,
min_savings_bytes: int = DEFAULT_MIN_SAVINGS_BYTES,
) -> OptimizationResult | None:
"""Optimize a single image file in-place.
:param path: Path to the image file.
:param min_savings_pct: Minimum percentage savings to keep the result.
If savings are below this threshold, the original file is restored.
:param min_savings_bytes: Minimum absolute byte savings to keep the result.
Prevents noisy diffs for tiny files where even a high percentage
represents negligible absolute savings.
:return: An {class}`OptimizationResult` if the file was optimized, or
`None` if the format is unsupported, the required tool is missing,
or savings were below the threshold.
"""
ext = path.suffix.lower()
entry = OPTIMIZERS.get(ext)
if not entry:
logging.warning(f"No optimizer for {ext!r}: {path}")
return None
tool_name, optimizer_fn = entry
if not _check_tool(tool_name):
logging.warning(f"{tool_name!r} not found on $PATH, skipping {path}")
return None
before_bytes = path.stat().st_size
if before_bytes == 0:
return None
# The backup lives outside the repository: a run killed mid-optimization
# would otherwise strand a `.bak` beside the image, where the autofix job
# that called this would stage it as a new file.
with tempfile.TemporaryDirectory(prefix="repomatic-images-") as tmpdir:
backup = Path(tmpdir) / path.name
try:
shutil.copy2(str(path), str(backup))
optimizer_fn(path)
after_bytes = path.stat().st_size
result = OptimizationResult(
path=path,
before_bytes=before_bytes,
after_bytes=after_bytes,
)
if (
result.saved_pct < min_savings_pct
or result.saved_bytes < min_savings_bytes
):
# Savings too small: restore the original.
shutil.copy2(str(backup), str(path))
if result.saved_bytes < min_savings_bytes:
logging.info(
f"Skipped {path}: {format_file_size(result.saved_bytes)} saved "
f"< {format_file_size(min_savings_bytes)} threshold."
)
else:
logging.info(
f"Skipped {path}: {result.saved_pct:.1f}% savings "
f"< {min_savings_pct}% threshold."
)
return None
logging.info(
f"Optimized {path}: "
f"{format_file_size(before_bytes)} β {format_file_size(after_bytes)} "
f"({result.saved_pct:.1f}% savings)."
)
except subprocess.CalledProcessError as exc:
# Restore the original on failure.
if backup.exists():
shutil.copy2(str(backup), str(path))
logging.warning(f"Failed to optimize {path}: {exc.stderr or exc}")
return None
else:
return result
[docs]
def optimize_images(
image_files: Sequence[Path],
min_savings_pct: float = DEFAULT_MIN_SAVINGS_PCT,
min_savings_bytes: int = DEFAULT_MIN_SAVINGS_BYTES,
) -> list[OptimizationResult]:
"""Optimize a list of image files.
:param image_files: Paths to image files.
:param min_savings_pct: Minimum percentage savings to keep an optimization.
:param min_savings_bytes: Minimum absolute byte savings to keep an optimization.
:return: List of results for files that were successfully optimized.
"""
results = []
for path in image_files:
result = optimize_image(
path,
min_savings_pct=min_savings_pct,
min_savings_bytes=min_savings_bytes,
)
if result is not None:
results.append(result)
# Sort by bytes saved descending (largest savings first).
results.sort(key=lambda r: r.saved_bytes, reverse=True)
return results
[docs]
def generate_markdown_summary(results: list[OptimizationResult]) -> str:
"""Generate a markdown summary table of optimization results.
Produces a table similar to `calibreapp/image-actions` output, showing
before/after sizes and percentage improvement for each optimized file.
"""
if not results:
return "No images were optimized."
total_before = sum(r.before_bytes for r in results)
total_after = sum(r.after_bytes for r in results)
total_saved = total_before - total_after
total_pct = (total_saved / total_before * 100) if total_before else 0
rows = [
[
f"`{r.path}`",
format_file_size(r.before_bytes),
format_file_size(r.after_bytes),
f"{r.saved_pct:.1f}%",
]
for r in results
]
table = render_table(
rows,
headers=["Filename", "Before", "After", "Improvement"],
table_format=TableFormat.GITHUB,
colalign=("left", "right", "right", "right"),
)
headline = (
f"Compression reduced images by **{total_pct:.1f}%**, "
f"saving **{format_file_size(total_saved)}**."
)
return f"{headline}\n\n{table}"