← Files note Workspace|ROGNALIAARCHIVED FILE
skills/note-image/scripts/prepare_note_image.py
12.3 KB · Oct 2, 2026 · 00:33 UTC
#!/usr/bin/env python3
"""Center-crop and resize pixels only; never add text or decorative overlays."""
from __future__ import annotations
import argparse
import hashlib
import json
import os
import shutil
import struct
import subprocess
import sys
from pathlib import Path
from typing import Callable, Optional, Tuple
from png_resize import (
PngResizeError,
PngResizeUnsupported,
resize_png_bytes,
validate_png_bytes,
)
MAX_IMAGE_BYTES = 50_000_000
def png_dimensions(path: Path) -> Optional[Tuple[int, int]]:
with path.open("rb") as handle:
header = handle.read(24)
if header[:8] != b"\x89PNG\r\n\x1a\n" or header[12:16] != b"IHDR":
return None
return struct.unpack(">II", header[16:24])
def jpeg_dimensions(path: Path) -> Optional[Tuple[int, int]]:
sof_markers = {
0xC0, 0xC1, 0xC2, 0xC3, 0xC5, 0xC6, 0xC7,
0xC9, 0xCA, 0xCB, 0xCD, 0xCE, 0xCF,
}
with path.open("rb") as handle:
if handle.read(2) != b"\xff\xd8":
return None
while True:
prefix = handle.read(1)
if not prefix:
return None
if prefix != b"\xff":
continue
marker = handle.read(1)
while marker == b"\xff":
marker = handle.read(1)
if not marker or marker in (b"\xd8", b"\xd9"):
continue
if marker == b"\xda":
return None
length_bytes = handle.read(2)
if len(length_bytes) != 2:
return None
length = struct.unpack(">H", length_bytes)[0]
if length < 2:
return None
if marker[0] in sof_markers:
payload = handle.read(5)
if len(payload) != 5:
return None
height, width = struct.unpack(">HH", payload[1:5])
return width, height
handle.seek(length - 2, 1)
def command_dimensions(path: Path) -> Optional[Tuple[int, int]]:
if shutil.which("sips"):
try:
result = subprocess.run(
["sips", "-g", "pixelWidth", "-g", "pixelHeight", str(path)],
check=True,
capture_output=True,
text=True,
)
width = height = None
for line in result.stdout.splitlines():
if "pixelWidth:" in line:
width = int(line.rsplit(":", 1)[1].strip())
elif "pixelHeight:" in line:
height = int(line.rsplit(":", 1)[1].strip())
if width and height:
return width, height
except (OSError, ValueError, subprocess.CalledProcessError):
pass
if shutil.which("identify"):
try:
result = subprocess.run(
["identify", "-format", "%w %h", str(path)],
check=True,
capture_output=True,
text=True,
)
width_text, height_text = result.stdout.strip().split()
return int(width_text), int(height_text)
except (OSError, ValueError, subprocess.CalledProcessError):
pass
return None
def dimensions(path: Path) -> Tuple[int, int]:
result = png_dimensions(path) or jpeg_dimensions(path) or command_dimensions(path)
if result is None:
raise RuntimeError(f"画像サイズを確認できません: {path}")
return result
def native_format(path: Path) -> Optional[str]:
if png_dimensions(path) is not None:
return "png"
if jpeg_dimensions(path) is not None:
return "jpeg"
return None
def resize_with_pillow(source: Path, output: Path, width: int, height: int) -> bool:
try:
from PIL import Image, ImageOps # type: ignore
except ImportError:
return False
with Image.open(source) as image:
image = ImageOps.exif_transpose(image)
resampling = getattr(Image, "Resampling", Image)
fitted = ImageOps.fit(
image,
(width, height),
method=resampling.LANCZOS,
centering=(0.5, 0.5),
)
fitted.save(output)
return True
def resize_with_builtin_png(
source: Path, output: Path, width: int, height: int
) -> bool:
if png_dimensions(source) is None or output.suffix.lower() != ".png":
return False
try:
resized = resize_png_bytes(source.read_bytes(), width, height)
except PngResizeUnsupported:
return False
except PngResizeError as exc:
raise RuntimeError(f"PNGの安全な整形を続けられません: {exc}") from exc
with output.open("xb") as handle:
handle.write(resized)
handle.flush()
os.fsync(handle.fileno())
return True
def resize_with_imagemagick(
source: Path, output: Path, width: int, height: int
) -> bool:
executable = shutil.which("magick") or shutil.which("convert")
if not executable:
return False
subprocess.run(
[
executable,
str(source),
"-auto-orient",
"-resize",
f"{width}x{height}^",
"-gravity",
"center",
"-extent",
f"{width}x{height}",
str(output),
],
check=True,
)
return True
def resize_with_sips(source: Path, output: Path, width: int, height: int) -> bool:
if not shutil.which("sips"):
return False
source_width, source_height = dimensions(source)
target_ratio = width / height
source_ratio = source_width / source_height
if source_ratio > target_ratio:
crop_height = source_height
crop_width = round(source_height * target_ratio)
else:
crop_width = source_width
crop_height = round(source_width / target_ratio)
shutil.copy2(source, output)
subprocess.run(
[
"sips",
"--cropToHeightWidth",
str(crop_height),
str(crop_width),
str(output),
],
check=True,
stdout=subprocess.DEVNULL,
)
subprocess.run(
[
"sips",
"--resampleHeightWidth",
str(height),
str(width),
str(output),
],
check=True,
stdout=subprocess.DEVNULL,
)
output_format = "png" if output.suffix.lower() == ".png" else "jpeg"
subprocess.run(
[
"sips",
"--setProperty",
"format",
output_format,
str(output),
],
check=True,
stdout=subprocess.DEVNULL,
)
return True
def resize_with_ffmpeg(source: Path, output: Path, width: int, height: int) -> bool:
if not shutil.which("ffmpeg"):
return False
video_filter = (
f"scale={width}:{height}:force_original_aspect_ratio=increase,"
f"crop={width}:{height}"
)
subprocess.run(
[
"ffmpeg",
"-loglevel",
"error",
"-y",
"-i",
str(source),
"-vf",
video_filter,
"-frames:v",
"1",
str(output),
],
check=True,
)
return True
def _parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
description="画像を中央基準で切り抜き、note用の寸法へ整えます。"
)
parser.add_argument("source", type=Path)
parser.add_argument("output", type=Path)
parser.add_argument("--width", type=int, required=True)
parser.add_argument("--height", type=int, required=True)
return parser
def main(argv: Optional[list[str]] = None) -> int:
args = _parser().parse_args(argv)
if args.width <= 0 or args.height <= 0:
raise RuntimeError("幅と高さは正の整数で指定してください。")
if not args.source.is_absolute() or not args.output.is_absolute():
raise RuntimeError("入力画像と出力先は絶対pathで指定してください。")
if not args.source.is_file() or args.source.is_symlink():
raise RuntimeError(f"入力画像が見つからないか安全ではありません: {args.source}")
if args.source.stat().st_size <= 0 or args.source.stat().st_size > MAX_IMAGE_BYTES:
raise RuntimeError("入力画像のfile sizeが許容範囲外です。")
if args.output.exists() or args.output.is_symlink():
raise RuntimeError(f"出力先がすでに存在します: {args.output}")
if args.source.resolve() == args.output.resolve():
raise RuntimeError("入力画像と出力先は分けてください。")
suffix = args.output.suffix.lower()
if suffix not in {".png", ".jpg", ".jpeg"}:
raise RuntimeError("出力形式はPNGまたはJPEGを指定してください。")
desired_format = "png" if suffix == ".png" else "jpeg"
args.output.parent.mkdir(parents=True, exist_ok=True)
if not args.output.parent.is_dir() or args.output.parent.is_symlink():
raise RuntimeError("出力先のdirectoryが安全ではありません。")
source_size = dimensions(args.source)
source_format = native_format(args.source)
created = False
try:
exact_copy = (
source_size == (args.width, args.height)
and source_format == desired_format
)
if exact_copy and source_format == "png":
try:
validate_png_bytes(args.source.read_bytes())
except PngResizeUnsupported:
exact_copy = False
except PngResizeError as exc:
raise RuntimeError(
f"PNGの安全性を確認できません: {exc}"
) from exc
if exact_copy:
shutil.copy2(args.source, args.output)
backend = "copy"
else:
backends: Tuple[
Tuple[str, Callable[[Path, Path, int, int], bool]], ...
] = (
("python-png", resize_with_builtin_png),
("pillow", resize_with_pillow),
("imagemagick", resize_with_imagemagick),
("sips", resize_with_sips),
("ffmpeg", resize_with_ffmpeg),
)
backend = ""
for name, handler in backends:
if handler(args.source, args.output, args.width, args.height):
backend = name
break
if not backend:
raise RuntimeError(
"画像を整形できる処理系がありません。Pillow、ImageMagick、sips、"
"ffmpegのいずれかが必要です。"
)
created = args.output.exists()
output_size = dimensions(args.output)
if output_size != (args.width, args.height):
raise RuntimeError(
f"出力寸法が一致しません: {output_size[0]}x{output_size[1]}"
)
output_format = native_format(args.output)
if output_format != desired_format:
raise RuntimeError("出力画像の実形式が拡張子と一致しません。")
print(
json.dumps(
{
"status": "created",
"backend": backend,
"source": str(args.source),
"source_width": source_size[0],
"source_height": source_size[1],
"source_format": source_format,
"source_sha256": hashlib.sha256(args.source.read_bytes()).hexdigest(),
"output": str(args.output),
"output_width": output_size[0],
"output_height": output_size[1],
"output_format": output_format,
"output_sha256": hashlib.sha256(args.output.read_bytes()).hexdigest(),
"external_actions": [],
},
ensure_ascii=False,
sort_keys=True,
)
)
return 0
except Exception:
if created or args.output.exists():
try:
args.output.unlink()
except FileNotFoundError:
pass
raise
if __name__ == "__main__":
try:
raise SystemExit(main())
except (OSError, RuntimeError, subprocess.CalledProcessError) as exc:
print(f"画像処理に失敗しました: {exc}", file=sys.stderr)
raise SystemExit(2) from exc
SHA-256: e7b77c4d205ae22a7c87525d0ef5b2e162d59fbbed79972bf5260a97275d73c2