← Files note Studio mini|ROGNALIAARCHIVED FILE

skills/note-studio/scripts/prepare_note_image.py

8.14 KB · Oct 3, 2026 · 06:32 UTC

↓ Download file

#!/usr/bin/env python3
"""Center-crop and resize a generated image for note without a hard dependency."""

from __future__ import annotations

import argparse
import json
import shutil
import struct
import subprocess
import sys
from pathlib import Path


def png_dimensions(path: Path) -> tuple[int, int] | None:
    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) -> tuple[int, int] | None:
    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]
            marker_value = marker[0]
            if marker_value 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) -> tuple[int, int] | None:
    if shutil.which("sips"):
        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
    if shutil.which("identify"):
        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)
    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 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)
        fitted = ImageOps.fit(
            image,
            (width, height),
            method=Image.Resampling.LANCZOS,
            centering=(0.5, 0.5),
        )
        fitted.save(output)
    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,
    )
    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 parse_args() -> argparse.Namespace:
    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.parse_args()


def main() -> int:
    args = parse_args()
    if args.width <= 0 or args.height <= 0:
        raise SystemExit("幅と高さは正の整数で指定してください。")
    if not args.source.is_file():
        raise SystemExit(f"入力画像が見つかりません: {args.source}")
    if args.output.exists():
        raise SystemExit(f"出力先がすでに存在します: {args.output}")
    args.output.parent.mkdir(parents=True, exist_ok=True)

    source_size = dimensions(args.source)
    if source_size == (args.width, args.height):
        shutil.copy2(args.source, args.output)
        backend = "copy"
    else:
        backends = (
            ("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 SystemExit(
                "画像を整形できる処理系がありません。Pillow、ImageMagick、sips、"
                "ffmpegのいずれかが必要です。"
            )

    output_size = dimensions(args.output)
    if output_size != (args.width, args.height):
        raise SystemExit(
            f"出力寸法が一致しません: {output_size[0]}x{output_size[1]}"
        )
    print(
        json.dumps(
            {
                "backend": backend,
                "source": str(args.source),
                "source_width": source_size[0],
                "source_height": source_size[1],
                "output": str(args.output),
                "output_width": output_size[0],
                "output_height": output_size[1],
            },
            ensure_ascii=False,
            sort_keys=True,
        )
    )
    return 0


if __name__ == "__main__":
    try:
        raise SystemExit(main())
    except subprocess.CalledProcessError as error:
        print(f"画像処理に失敗しました: {error}", file=sys.stderr)
        raise SystemExit(1) from error

SHA-256: 99b3dce3348e0ac87de1c9ee0d5256fae597b6d7c492f1a584b537a4d5eae528