#!/usr/bin/env python3 """为 MAOMOMO 配图叠加 45° 平铺半透明水印。 默认水印:MAOMOMO.COM,白色半透明,45° 对角线平铺。 用法: uv run python scripts/maomomo_tile_watermark.py --input assets --output watermarked_assets uv run python scripts/maomomo_tile_watermark.py --input assets/example.png --output assets/example-watermarked.png 说明: 脚本使用 Pillow。本机 Python 没有 Pillow 且存在 uv 时,会自动用 `uv run --with pillow python ...` 重启一次,不需要把依赖写入项目。 """ from __future__ import annotations import argparse import os from pathlib import Path import shutil import subprocess import sys from typing import Iterable, Iterator, Optional, Tuple, Any SUPPORTED_EXTS = {".png", ".jpg", ".jpeg", ".webp"} def die(message: str, code: int = 1) -> None: print(f"错误:{message}", file=sys.stderr) raise SystemExit(code) def ensure_pillow() -> Tuple[Any, Any, Any]: try: from PIL import Image, ImageDraw, ImageFont return Image, ImageDraw, ImageFont except ModuleNotFoundError as exc: if exc.name != "PIL": raise uv = shutil.which("uv") if uv and os.environ.get("MAOMOMO_WATERMARK_REEXEC") != "1": env = os.environ.copy() env["MAOMOMO_WATERMARK_REEXEC"] = "1" cmd = [uv, "run", "--with", "pillow>=10.0.0", "python", str(Path(__file__).resolve()), *sys.argv[1:]] raise SystemExit(subprocess.call(cmd, env=env)) die("缺少 Pillow。请使用 `uv run python scripts/maomomo_tile_watermark.py ...` 运行,或安装 Pillow。") def parse_color(value: str) -> Tuple[int, int, int]: text = value.strip() if text.startswith("#"): text = text[1:] if len(text) != 6: die("颜色必须是 #RRGGBB,例如 #FFFFFF") try: return tuple(int(text[i : i + 2], 16) for i in (0, 2, 4)) # type: ignore[return-value] except ValueError: die("颜色必须是有效十六进制,例如 #FFFFFF") def load_font(ImageFont: Any, font_path: Optional[str], font_size: int) -> Any: candidates = [] if font_path: candidates.append(Path(font_path).expanduser()) candidates.extend( Path(p) for p in [ "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", "/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc", "/System/Library/Fonts/Supplemental/Arial Bold.ttf", ] ) for candidate in candidates: if candidate.exists(): try: return ImageFont.truetype(str(candidate), font_size) except OSError: continue return ImageFont.load_default() def iter_images(input_path: Path) -> Iterator[Path]: if input_path.is_file(): if input_path.suffix.lower() not in SUPPORTED_EXTS: die(f"不支持的图片格式:{input_path}") yield input_path return if not input_path.is_dir(): die(f"输入不存在:{input_path}") for path in sorted(input_path.rglob("*")): if path.is_file() and path.suffix.lower() in SUPPORTED_EXTS: yield path def resolve_output(input_root: Path, image_path: Path, output_root: Path) -> Path: if input_root.is_file(): return output_root rel = image_path.relative_to(input_root) return output_root / rel def make_tile( Image: Any, ImageDraw: Any, text: str, font: Any, color: Tuple[int, int, int], alpha: int, angle: float, padding: int, ) -> Any: dummy = Image.new("RGBA", (1, 1), (0, 0, 0, 0)) draw = ImageDraw.Draw(dummy) bbox = draw.textbbox((0, 0), text, font=font) text_w = bbox[2] - bbox[0] text_h = bbox[3] - bbox[1] tile_w = max(1, text_w + padding * 2) tile_h = max(1, text_h + padding * 2) tile = Image.new("RGBA", (tile_w, tile_h), (0, 0, 0, 0)) tile_draw = ImageDraw.Draw(tile) tile_draw.text((padding, padding), text, font=font, fill=(*color, alpha)) return tile.rotate(angle, expand=True, resample=Image.Resampling.BICUBIC) def apply_watermark( image_path: Path, output_path: Path, *, text: str, angle: float, alpha: int, color: Tuple[int, int, int], font_size: int, font_path: Optional[str], spacing: int, quality: int, ) -> None: if alpha < 0 or alpha > 255: die("alpha 必须在 0-255 之间") if spacing < 0: die("spacing 不能为负数") Image, ImageDraw, ImageFont = ensure_pillow() font = load_font(ImageFont, font_path, font_size) with Image.open(image_path) as src: base = src.convert("RGBA") width, height = base.size diagonal = int((width * width + height * height) ** 0.5) tile = make_tile(Image, ImageDraw, text, font, color, alpha, angle, spacing) overlay = Image.new("RGBA", (width, height), (0, 0, 0, 0)) step_x = max(1, tile.width + spacing) step_y = max(1, tile.height + spacing) start_x = -diagonal start_y = -diagonal for y in range(start_y, height + diagonal, step_y): for x in range(start_x, width + diagonal, step_x): overlay.alpha_composite(tile, (x, y)) out = Image.alpha_composite(base, overlay) output_path.parent.mkdir(parents=True, exist_ok=True) suffix = output_path.suffix.lower() if suffix in {".jpg", ".jpeg"}: out = out.convert("RGB") out.save(output_path, quality=quality, optimize=True) elif suffix == ".webp": out.save(output_path, quality=quality, method=6) else: out.save(output_path) def build_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser(description="为图片叠加 MAOMOMO.COM 45° 平铺半透明水印。") parser.add_argument("--input", required=True, help="输入图片文件或目录。") parser.add_argument("--output", required=True, help="输出图片文件或目录。目录模式会保持相对路径。") parser.add_argument("--text", default="MAOMOMO.COM", help="水印文字,默认 MAOMOMO.COM。") parser.add_argument("--angle", type=float, default=45.0, help="水印旋转角度,默认 45。") parser.add_argument("--alpha", type=int, default=72, help="透明度 0-255,默认 72。") parser.add_argument("--color", default="#FFFFFF", help="水印颜色,默认 #FFFFFF。") parser.add_argument("--font-size", type=int, default=42, help="字体大小,默认 42。") parser.add_argument("--font", default=None, help="可选字体路径。") parser.add_argument("--spacing", type=int, default=120, help="水印间距 / 内边距,默认 120。") parser.add_argument("--quality", type=int, default=95, help="JPG/WEBP 输出质量,默认 95。") return parser def main(argv: Optional[Iterable[str]] = None) -> int: parser = build_parser() args = parser.parse_args(list(argv) if argv is not None else None) input_path = Path(args.input).expanduser().resolve() output_path = Path(args.output).expanduser().resolve() color = parse_color(args.color) images = list(iter_images(input_path)) if not images: die(f"没有找到支持的图片:{input_path}") if input_path.is_dir() and output_path.exists() and output_path.is_file(): die("输入为目录时,输出必须是目录路径") results = [] for image_path in images: target = resolve_output(input_path, image_path, output_path) apply_watermark( image_path, target, text=args.text, angle=args.angle, alpha=args.alpha, color=color, font_size=args.font_size, font_path=args.font, spacing=args.spacing, quality=args.quality, ) results.append((image_path, target)) for src, dst in results: print(f"watermarked: {src} -> {dst}") return 0 if __name__ == "__main__": raise SystemExit(main())