Files

527 lines
19 KiB
Python

#!/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 <project_path> <source> --output <filename.png> [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 <project_path>/images (no path components).",
)
parser.add_argument(
"--output",
required=True,
help="New bare .png filename under <project_path>/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())