#!/usr/bin/env python3 """ PPT Master - Image Treatment Create a non-destructive PNG derivative from one project-local bitmap while preserving its display dimensions, alpha mask, and any matching web provenance. Usage: python3 scripts/image_treat.py --output [options] Examples: python3 scripts/image_treat.py projects/demo hero.jpg --output hero_soft.png --blur 12 python3 scripts/image_treat.py projects/demo hero.jpg --output hero_duotone.png \ --contrast 1.1 --duotone "#14213D" "#FCA311" Dependencies: Pillow; project image-search dependencies when copying web provenance Treatments run in this fixed order: brightness, contrast, tone treatment (desaturate / grayscale / duotone), then Gaussian blur. """ from __future__ import annotations import argparse import copy import math import os import re import sys import tempfile from io import BytesIO from pathlib import Path from typing import Optional from PIL import ( Image, ImageCms, ImageEnhance, ImageFilter, ImageOps, UnidentifiedImageError, ) _SCRIPTS_DIR = Path(__file__).resolve().parent if str(_SCRIPTS_DIR) not in sys.path: sys.path.insert(0, str(_SCRIPTS_DIR)) from console_encoding import configure_utf8_stdio # noqa: E402 configure_utf8_stdio() _HEX_COLOR_RE = re.compile(r"^#?([0-9A-Fa-f]{6})$") def _read_sources_manifest(path: Path) -> dict: from image_search import _read_existing_manifest return _read_existing_manifest(path) def _write_sources_manifest(path: Path, item: dict) -> Path: from image_search import write_sources_manifest return write_sources_manifest(path, item) def _finite_float(value: str) -> float: try: number = float(value) except ValueError as exc: raise argparse.ArgumentTypeError(f"expected a number, got {value!r}") from exc if not math.isfinite(number): raise argparse.ArgumentTypeError("value must be finite") return number def _nonnegative_float(value: str) -> float: number = _finite_float(value) if number < 0: raise argparse.ArgumentTypeError("value must be greater than or equal to 0") return number def _positive_float(value: str) -> float: number = _finite_float(value) if number <= 0: raise argparse.ArgumentTypeError("value must be greater than 0") return number def _unit_float(value: str) -> float: number = _finite_float(value) if not 0 <= number <= 1: raise argparse.ArgumentTypeError("value must be between 0 and 1") return number def _hex_color(value: str) -> str: match = _HEX_COLOR_RE.fullmatch(value) if match is None: raise argparse.ArgumentTypeError("color must be #RRGGBB or RRGGBB") return f"#{match.group(1).upper()}" def build_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser( description=( "Create a project-local PNG derivative. Processing order: brightness -> " "contrast -> desaturate/grayscale/duotone -> blur." ), formatter_class=argparse.RawDescriptionHelpFormatter, ) parser.add_argument("project_path", help="Project root containing an images/ directory.") parser.add_argument( "source", help="Existing bare bitmap filename under /images (no path components).", ) parser.add_argument( "--output", required=True, help="New bare .png filename under /images; existing files are never replaced.", ) parser.add_argument( "--brightness", type=_positive_float, help="Brightness factor greater than 0; 1 is unchanged.", ) parser.add_argument( "--contrast", type=_positive_float, help="Contrast factor greater than 0; 1 is unchanged.", ) tone_group = parser.add_mutually_exclusive_group() tone_group.add_argument( "--desaturate", type=_unit_float, help="Remove this fraction of color (0..1); 1 is grayscale.", ) tone_group.add_argument( "--grayscale", action="store_true", help="Convert RGB content to grayscale while preserving any alpha mask.", ) tone_group.add_argument( "--duotone", nargs=2, type=_hex_color, metavar=("SHADOW", "HIGHLIGHT"), help="Map sRGB luminance between two sRGB #RRGGBB colors.", ) parser.add_argument( "--blur", type=_nonnegative_float, help="Gaussian blur radius greater than or equal to 0; 0 is unchanged.", ) return parser def _validate_bare_filename(value: str, *, field_name: str) -> str: if ( not value.strip() or value in {".", ".."} or "/" in value or "\\" in value or ":" in value or Path(value).is_absolute() ): raise ValueError( f"{field_name} must be a bare filename without path components: {value!r}" ) return value def _assert_output_absent(output_path: Path) -> None: for child in output_path.parent.iterdir(): if child.name.casefold() == output_path.name.casefold(): raise ValueError(f"output already exists or conflicts by casing: {child}") def _resolve_paths(project_value: str, source_name: str, output_name: str) -> tuple[Path, Path, Path]: source_name = _validate_bare_filename(source_name, field_name="source") output_name = _validate_bare_filename(output_name, field_name="output") if Path(output_name).suffix.casefold() != ".png": raise ValueError("output must use the .png extension") if source_name.casefold() == output_name.casefold(): raise ValueError("output must differ from source, including filename casing") project_input = Path(project_value).expanduser() try: project_path = project_input.resolve(strict=True) except (OSError, RuntimeError) as exc: raise ValueError(f"project path does not resolve safely: {project_input} ({exc})") from exc if not project_path.is_dir(): raise ValueError(f"project path is not a directory: {project_path}") images_link = project_path / "images" if images_link.is_symlink(): raise ValueError(f"project images directory must not be a symlink: {images_link}") if not images_link.is_dir(): raise ValueError(f"project images directory does not exist: {images_link}") images_path = images_link.resolve(strict=True) if images_path.parent != project_path: raise ValueError(f"project images directory escapes the project root: {images_link}") source_link = images_path / source_name if source_link.is_symlink(): raise ValueError(f"source must not be a symlink: {source_link}") try: source_path = source_link.resolve(strict=True) except (OSError, RuntimeError) as exc: raise ValueError(f"source does not resolve safely: {source_link} ({exc})") from exc if source_path.parent != images_path or not source_path.is_file(): raise ValueError(f"source must be a file directly under project images/: {source_link}") output_path = images_path / output_name _assert_output_absent(output_path) if output_path.exists() or output_path.is_symlink(): raise ValueError(f"output already exists: {output_path}") if output_path.parent.resolve() != images_path: raise ValueError(f"output escapes the project images directory: {output_path}") return images_path, source_path, output_path def _treatment_plan(args: argparse.Namespace) -> list[dict]: plan: list[dict] = [] if args.brightness is not None and args.brightness != 1: plan.append({"operation": "brightness", "factor": args.brightness}) if args.contrast is not None and args.contrast != 1: plan.append({"operation": "contrast", "factor": args.contrast}) if args.desaturate is not None and args.desaturate > 0: plan.append({"operation": "desaturate", "amount": args.desaturate}) elif args.grayscale: plan.append({"operation": "grayscale"}) elif args.duotone is not None: plan.append( { "operation": "duotone", "shadow": args.duotone[0], "highlight": args.duotone[1], } ) if args.blur is not None and args.blur > 0: plan.append({"operation": "blur", "radius": args.blur}) if not plan: raise ValueError( "select at least one effective treatment; identity values such as " "--brightness 1, --desaturate 0, or --blur 0 do not change the image" ) return plan def _validate_blur_radius(radius: Optional[float], width: int, height: int) -> None: if radius is None or radius <= 0: return effective_maximum = max(width, height) if radius > effective_maximum: raise ValueError( f"blur radius {radius:g} exceeds the effective maximum " f"{effective_maximum} for a {width}x{height} image; choose --blur " f"at or below {effective_maximum}" ) def _convert_duotone_source_to_srgb(image: Image.Image) -> tuple[Image.Image, bytes]: output_profile = ImageCms.ImageCmsProfile(ImageCms.createProfile("sRGB")) output_icc = output_profile.tobytes() source_icc = image.info.get("icc_profile") if not source_icc: return image.convert("RGB"), output_icc if not isinstance(source_icc, bytes): raise ValueError( "source ICC profile has an unsupported representation; convert the " "source to sRGB or remove the invalid profile, then retry" ) try: input_profile = ImageCms.ImageCmsProfile(BytesIO(source_icc)) converted = ImageCms.profileToProfile( image, input_profile, output_profile, outputMode="RGB", ) except (ImageCms.PyCMSError, OSError, TypeError, ValueError) as exc: raise ValueError( "source ICC profile is invalid or incompatible with the image mode; " "convert the source to sRGB or remove the invalid profile, then retry" ) from exc if converted is None: raise RuntimeError("ICC conversion did not produce an image") return converted, output_icc def _has_alpha(image: Image.Image) -> bool: return "A" in image.getbands() or "transparency" in image.info def _apply_treatments( source_path: Path, temporary_path: Path, args: argparse.Namespace, ) -> tuple[int, int]: try: with Image.open(source_path) as source: if int(getattr(source, "n_frames", 1)) != 1: raise RuntimeError( f"animated or multi-frame images are unsupported: {source_path}" ) oriented = ImageOps.exif_transpose(source) try: oriented.load() width, height = oriented.size _validate_blur_radius(args.blur, width, height) has_alpha = _has_alpha(oriented) rgba = oriented.convert("RGBA") if has_alpha else None alpha = rgba.getchannel("A") if rgba is not None else None if args.duotone is not None: rgb, icc_profile = _convert_duotone_source_to_srgb(oriented) else: keep_icc = oriented.mode in {"RGB", "RGBA"} icc_profile = oriented.info.get("icc_profile") if keep_icc else None rgb = rgba.convert("RGB") if rgba is not None else oriented.convert("RGB") if args.brightness is not None and args.brightness != 1: rgb = ImageEnhance.Brightness(rgb).enhance(args.brightness) if args.contrast is not None and args.contrast != 1: rgb = ImageEnhance.Contrast(rgb).enhance(args.contrast) if args.desaturate is not None and args.desaturate > 0: rgb = ImageEnhance.Color(rgb).enhance(1 - args.desaturate) elif args.grayscale: rgb = ImageOps.grayscale(rgb).convert("RGB") elif args.duotone is not None: rgb = ImageOps.colorize( ImageOps.grayscale(rgb), black=args.duotone[0], white=args.duotone[1], ) if args.blur is not None and args.blur > 0: rgb = rgb.filter(ImageFilter.GaussianBlur(radius=args.blur)) result = rgb.convert("RGBA") if alpha is not None else rgb if alpha is not None: result.putalpha(alpha) save_options = {"format": "PNG"} if isinstance(icc_profile, bytes) and icc_profile: save_options["icc_profile"] = icc_profile result.save(temporary_path, **save_options) result.close() if rgba is not None: rgba.close() if alpha is not None: alpha.close() rgb.close() return width, height finally: if oriented is not source: oriented.close() except (OSError, UnidentifiedImageError) as exc: raise RuntimeError(f"unable to read or treat source image {source_path}: {exc}") from exc def _rewrite_attribution_text(item: dict, source_name: str, output_name: str) -> str: value = item.get("attribution_text") if not isinstance(value, str) or not value: return value if isinstance(value, str) else "" if not value.startswith(source_name): return value remainder = value[len(source_name) :] if remainder and not (remainder[0].isspace() or remainder[0] in "—–-:"): return value return output_name + remainder def _prepare_provenance( manifest_path: Path, source_name: str, output_name: str, treatments: list[dict], ) -> Optional[dict]: if manifest_path.is_symlink(): raise ValueError(f"image source manifest must not be a symlink: {manifest_path}") if not manifest_path.exists(): return None payload = _read_sources_manifest(manifest_path) items = payload.get("items") or [] source_item = None for item in items: filename = item.get("filename") if not isinstance(filename, str): continue if filename.casefold() == output_name.casefold(): raise ValueError( f"output already has an image_sources.json record: {filename!r}" ) if filename.casefold() == source_name.casefold(): if filename != source_name: raise ValueError( "source filename casing does not match image_sources.json: " f"{source_name!r} vs {filename!r}" ) source_item = item if source_item is None: return None derived_item = copy.deepcopy(source_item) derived_item["filename"] = output_name derived_item["attribution_text"] = _rewrite_attribution_text( source_item, source_name, output_name, ) derived_item["derived_from"] = source_name derived_item["treatments"] = copy.deepcopy(treatments) return derived_item def _assert_manifest_output_absent(manifest_path: Path, output_name: str) -> None: payload = _read_sources_manifest(manifest_path) for item in payload.get("items") or []: filename = item.get("filename") if isinstance(filename, str) and filename.casefold() == output_name.casefold(): raise RuntimeError( f"output already has an image_sources.json record: {filename!r}" ) def _is_staged_output(temporary_path: Path, output_path: Path) -> bool: try: return not output_path.is_symlink() and os.path.samefile(temporary_path, output_path) except OSError: return False def _write_derivative( source_path: Path, output_path: Path, manifest_path: Path, provenance_item: Optional[dict], args: argparse.Namespace, ) -> None: fd, temporary_name = tempfile.mkstemp( prefix=f".{output_path.stem}.", suffix=".tmp.png", dir=str(output_path.parent), ) os.close(fd) temporary_path = Path(temporary_name) try: width, height = _apply_treatments(source_path, temporary_path, args) if provenance_item is not None: provenance_item["width"] = width provenance_item["height"] = height _assert_manifest_output_absent(manifest_path, output_path.name) _assert_output_absent(output_path) try: os.chmod(temporary_path, 0o644) except OSError: pass try: os.link(temporary_path, output_path) except FileExistsError as exc: raise RuntimeError( f"output appeared during processing and will not be replaced: {output_path}" ) from exc if provenance_item is not None: try: if not _is_staged_output(temporary_path, output_path): raise RuntimeError( f"installed output changed before provenance commit: {output_path}" ) _assert_manifest_output_absent(manifest_path, output_path.name) _write_sources_manifest(manifest_path, provenance_item) except Exception as exc: try: if _is_staged_output(temporary_path, output_path): output_path.unlink() except OSError as cleanup_exc: raise RuntimeError( f"manifest update failed and output rollback also failed: {cleanup_exc}" ) from exc raise RuntimeError(f"manifest update failed; output was rolled back: {exc}") from exc finally: try: temporary_path.unlink() except FileNotFoundError: pass def main(argv: Optional[list[str]] = None) -> int: parser = build_parser() args = parser.parse_args(argv) try: images_path, source_path, output_path = _resolve_paths( args.project_path, args.source, args.output, ) treatments = _treatment_plan(args) manifest_path = images_path / "image_sources.json" provenance_item = _prepare_provenance( manifest_path, args.source, args.output, treatments, ) _write_derivative( source_path, output_path, manifest_path, provenance_item, args, ) except (OSError, RuntimeError, ValueError) as exc: print(f"Error: {exc}", file=sys.stderr) return 1 print(output_path) return 0 if __name__ == "__main__": raise SystemExit(main())