PDF に見開きや綴じ方向などの情報を付与する

PDF ファイルは推奨される表示方法(見開き・綴じ方向など)をファイル内に情報として持つことができる。しかし書籍を自炊して得られた PDF ファイルはこの情報を持たないことが多い(少なくとも ScanSnap には自動でこの情報を付与する機能はなさそうである)。これらの情報は、Adobe Acrobat があれば GUI で設定可能だが、無い場合は設定できるソフトが意外と無い。これらの情報の付与を C# で行うコードを公開してくださっているかたがいたので(下のURL)、ChatGPT を用いた vibe coding によってそのロジックを Python (+pikepdf) に移植した。コードはこのブログの末尾に示す。

https://qiita.com/TETSURO1999/items/2bf3bb129b6a7045ac01

Tkinter および pikepdf が利用可能な Python 環境で以下のようにコードを実行すると GUI で操作できる。

% python pdf_catalog_set_gui.py

1ページ目が表紙になっている右綴じ本(右から左に読む本)は「和書など」のプリセットを、同様の左綴じ本は「洋書など」のプリセットを使用すればよい。設定される見開き・綴じ方向が想定の通りとなることは、Adobe Acrobat Reader と Skim (いずれも macOS 版)で確認した。

PDF ファイルの catalog 設定と実際の表示との間の齟齬について

このプログラムを作っている際に、PDF の仕様に関して違和感を覚えた点を記しておく。

下の URL に記載があるように、PDF の見開き設定は PageLayout 情報、綴じ方向の設定は ViewerPreferences の Direction 情報として保存されている。

https://qiita.com/TETSURO1999/items/e7a69026bdf8b5e8c631

PageLayoutTwoPageLeft は「一度に2ページを、奇数ページを左側に表示する」という意味で、 TwoPageRight は「一度に2ページを、奇数ページを右側に表示する」という意味となっている。

また、ViewerPreferences の Direction は、 L2R は「左から右へ読む」、 R2L は「右から左へ読む」という意味となっている。

表紙の有無はこれらの情報の組み合わせで表現されている( TwoPageRight でありかつ L2R ならば、1ページ目は単独で表紙として存在するだろう、といった形)。

この定義をそのまま受け取ると、日本語の縦書き書籍は奇数ページが左にあり、かつ右から左に読むので、 TwoPageLeft かつ R2L を同時に設定するのが正しいように思える。しかしこう設定すると想定される挙動とはならず、書籍と同じように表示するためには TwoPageRight かつ R2L を設定しなければならない。Adobe Acrobat Reader でも Skim でも挙動は共通である。おそらく、 PageLayout の左右の定義が R2L では逆転するという実装になっていると思われる。

PDF の仕様書(下のURL)を見ると、TwoPageLeft / TwoPageRight は “Display the pages two at a time, with odd-numbered pages on the left/right” と定義されているようなので、これを文字通り取るならば Adobe Acrobat Reader や Skim の挙動は誤った実装と思われる。

PDF注意! https://opensource.adobe.com/dc-acrobat-sdk-docs/pdfstandards/PDF32000_2008.pdf

しかしデファクトスタンダードである Adobe Acrobat Reader がそのような実装になっている以上、それに従うのが良いだろう。仕様より実装が優先する(ダメ人間)。

コード

以下のコードに pdf_catalog_set_gui.py というファイル名を付けて保存して使用してください。

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
pdf_catalog_set_gui.py

GUI tool to set PDF Catalog entries:
  - /PageLayout
  - /ViewerPreferences << /Direction /R2L|L2R >>

Workflow:
  Open PDF with pikepdf -> edit Catalog -> save (xref rebuilt)

Features:
- Japanese presets (radio buttons) such as:
    "右綴じ(縦書き)表紙あり(和書など)" => TwoPageRight + R2L
    "左綴じ(横書き)表紙あり(洋書など)" => TwoPageRight + L2R
    "右綴じ(縦書き)表紙なし" => TwoPageLeft  + R2L
    "左綴じ(横書き)表紙なし" => TwoPageLeft  + L2R
- Manual controls via checkboxes + comboboxes
- Drag & drop (if tkinterdnd2 installed), otherwise file picker
- Outputs files to the same folder with a suffix indicating the conversion.

Requirements:
- Python 3.x
- tkinter (usually bundled)
- pikepdf (required): pip install pikepdf
- Optional for drag&drop: pip install tkinterdnd2

Run:
  python3 pdf_catalog_set_gui.py
"""

import sys
import tkinter as tk
from tkinter import ttk, filedialog, messagebox
from dataclasses import dataclass
from pathlib import Path
from typing import Optional, List, Dict


# =============================================================================
# Presets (Japanese-friendly)
# =============================================================================

PRESETS: Dict[str, Optional[Dict[str, Optional[str]]]] = {
    "右綴じ(縦書き)表紙あり(和書など)": {"pagelayout": "TwoPageRight", "direction": "R2L"},
    "左綴じ(横書き)表紙あり(洋書など)": {"pagelayout": "TwoPageRight", "direction": "L2R"},
    "右綴じ(縦書き)表紙なし": {"pagelayout": "TwoPageLeft",  "direction": "R2L"},
    "左綴じ(横書き)表紙なし": {"pagelayout": "TwoPageLeft",  "direction": "L2R"},
    "単ページ表示": {"pagelayout": "SinglePage", "direction": None},
    "設定しない(手動指定)": None,
}

LAYOUT_CHOICES = [
    "SinglePage",
    "OneColumn",
    "TwoColumnLeft",
    "TwoColumnRight",
    "TwoPageLeft",
    "TwoPageRight",
]

DIRECTION_CHOICES = ["L2R", "R2L"]


@dataclass(frozen=True)
class CatalogEdits:
    set_pagelayout: bool
    pagelayout_value: Optional[str]  # e.g. "TwoPageRight"
    set_direction: bool
    direction_value: Optional[str]   # "R2L" or "L2R"
    remove_pagelayout: bool
    remove_direction: bool


def _normalize_layout(layout: Optional[str]) -> Optional[str]:
    if layout is None:
        return None
    layout = layout.strip()
    if layout.startswith("/"):
        layout = layout[1:]
    return layout or None


def _normalize_direction(direction: Optional[str]) -> Optional[str]:
    if direction is None:
        return None
    d = direction.strip()
    if d.startswith("/"):
        d = d[1:]
    d = d.upper()
    if d not in ("R2L", "L2R"):
        raise ValueError("direction must be R2L or L2R")
    return d
# =============================================================================
# Orchestration
# =============================================================================

def process_pdf(in_pdf: Path, out_pdf: Path, edits: CatalogEdits) -> None:
    try:
        import pikepdf  # type: ignore
    except Exception as e:
        raise RuntimeError(
            "pikepdf is required for editing. Install it with: pip install pikepdf"
        ) from e

    with pikepdf.Pdf.open(str(in_pdf)) as pdf:
        root = pdf.Root

        if edits.remove_pagelayout and not edits.set_pagelayout:
            if "/PageLayout" in root:
                del root["/PageLayout"]

        if edits.set_pagelayout:
            val = _normalize_layout(edits.pagelayout_value)
            if val is None:
                raise RuntimeError("PageLayout is enabled but empty.")
            root["/PageLayout"] = pikepdf.Name("/" + val)

        if edits.remove_direction and not edits.set_direction:
            vp = root.get("/ViewerPreferences")
            if isinstance(vp, pikepdf.Dictionary) and "/Direction" in vp:
                del vp["/Direction"]
                if len(vp) == 0:
                    del root["/ViewerPreferences"]

        if edits.set_direction:
            dv = _normalize_direction(edits.direction_value)
            if dv is None:
                raise RuntimeError("Direction is enabled but empty.")
            vp = root.get("/ViewerPreferences")
            if not isinstance(vp, pikepdf.Dictionary):
                vp = pikepdf.Dictionary()
                root["/ViewerPreferences"] = vp
            vp["/Direction"] = pikepdf.Name("/" + dv)

        pdf.save(str(out_pdf))


# =============================================================================
# GUI helpers
# =============================================================================

def make_output_path(input_path: Path, suffix_tag: str) -> Path:
    stem = input_path.stem
    ext = input_path.suffix or ".pdf"
    return input_path.with_name(f"{stem}{suffix_tag}{ext}")


def safe_suffix(edits: CatalogEdits) -> str:
    parts = []
    if edits.set_pagelayout and edits.pagelayout_value:
        parts.append(f"PL-{edits.pagelayout_value}")
    if edits.set_direction and edits.direction_value:
        parts.append(f"DIR-{edits.direction_value}")
    if edits.remove_pagelayout and not edits.set_pagelayout:
        parts.append("rmPL")
    if edits.remove_direction and not edits.set_direction:
        parts.append("rmDIR")
    if not parts:
        parts.append("noedit")
    return "_converted_" + "_".join(parts)


def parse_dnd_files(data: str) -> List[str]:
    """
    Parse DND payload like:
      {path with spaces} {path2} ...
    """
    data = data.strip()
    if not data:
        return []
    out: List[str] = []
    cur = ""
    in_brace = False
    for ch in data:
        if ch == "{":
            in_brace = True
            cur = ""
        elif ch == "}":
            in_brace = False
            if cur:
                out.append(cur)
            cur = ""
        elif ch.isspace() and not in_brace:
            if cur:
                out.append(cur)
                cur = ""
        else:
            cur += ch
    if cur:
        out.append(cur)
    return out


# =============================================================================
# GUI App
# =============================================================================

class App:
    def __init__(self, root: tk.Tk):
        self.root = root
        root.title("PDF 表示設定(PageLayout / Direction)")
        root.geometry("820x820")

        self.files: List[Path] = []

        # Preset selection
        self.var_preset = tk.StringVar(value="設定しない(手動指定)")

        # Manual options
        self.var_set_pl = tk.BooleanVar(value=True)
        self.var_pl = tk.StringVar(value="TwoPageRight")
        self.var_rm_pl = tk.BooleanVar(value=False)

        self.var_set_dir = tk.BooleanVar(value=True)
        self.var_dir = tk.StringVar(value="R2L")
        self.var_rm_dir = tk.BooleanVar(value=False)

        self.var_suffix_mode = tk.StringVar(value="auto")  # auto / custom
        self.var_custom_suffix = tk.StringVar(value="_converted")

        # Layout
        frm = ttk.Frame(root, padding=12)
        frm.pack(fill="both", expand=True)

        # ---- Presets ----
        preset_frame = ttk.LabelFrame(frm, text="表示プリセット(おすすめ)", padding=10)
        preset_frame.pack(fill="x")

        # Use 2 columns for readability
        preset_names = list(PRESETS.keys())
        cols = 2
        for idx, name in enumerate(preset_names):
            r = idx // cols
            c = idx % cols
            rb = ttk.Radiobutton(
                preset_frame,
                text=name,
                value=name,
                variable=self.var_preset,
                command=self.apply_preset
            )
            rb.grid(row=r, column=c, sticky="w", padx=6, pady=2)
        for c in range(cols):
            preset_frame.grid_columnconfigure(c, weight=1)

        # ---- Manual ----
        opt = ttk.LabelFrame(frm, text="手動指定(必要なら微調整)", padding=10)
        opt.pack(fill="x", pady=(10, 0))

        row1 = ttk.Frame(opt)
        row1.pack(fill="x", pady=4)
        ttk.Checkbutton(row1, text="PageLayout を設定", variable=self.var_set_pl).pack(side="left")
        ttk.Label(row1, text="値:").pack(side="left", padx=(10, 4))
        ttk.Combobox(row1, textvariable=self.var_pl, values=LAYOUT_CHOICES, width=18, state="readonly").pack(side="left")
        ttk.Checkbutton(row1, text="PageLayout を削除", variable=self.var_rm_pl).pack(side="left", padx=(16, 0))

        row2 = ttk.Frame(opt)
        row2.pack(fill="x", pady=4)
        ttk.Checkbutton(row2, text="Direction を設定", variable=self.var_set_dir).pack(side="left")
        ttk.Label(row2, text="値:").pack(side="left", padx=(10, 4))
        ttk.Combobox(row2, textvariable=self.var_dir, values=DIRECTION_CHOICES, width=8, state="readonly").pack(side="left")
        ttk.Checkbutton(row2, text="Direction を削除", variable=self.var_rm_dir).pack(side="left", padx=(16, 0))

        # ---- Output naming ----
        outname = ttk.LabelFrame(frm, text="出力ファイル名(入力と同じフォルダに保存)", padding=10)
        outname.pack(fill="x", pady=(10, 0))

        row3 = ttk.Frame(outname)
        row3.pack(fill="x")
        ttk.Radiobutton(row3, text="自動(設定内容を付与)", value="auto", variable=self.var_suffix_mode).pack(side="left")
        ttk.Radiobutton(row3, text="固定サフィックス:", value="custom", variable=self.var_suffix_mode).pack(side="left", padx=(10, 0))
        ttk.Entry(row3, textvariable=self.var_custom_suffix, width=20).pack(side="left", padx=(6, 0))
        ttk.Label(row3, text="例: _converted").pack(side="left", padx=(8, 0))

        # ---- Files ----
        lf = ttk.LabelFrame(frm, text="入力PDF(ドラッグ&ドロップ または 追加)", padding=10)
        lf.pack(fill="both", expand=True, pady=(10, 0))

        btnrow = ttk.Frame(lf)
        btnrow.pack(fill="x")

        ttk.Button(btnrow, text="ファイル追加…", command=self.add_files_dialog).pack(side="left")
        ttk.Button(btnrow, text="リストクリア", command=self.clear_files).pack(side="left", padx=8)
        ttk.Button(btnrow, text="変換実行", command=self.run).pack(side="right")

        self.listbox = tk.Listbox(lf, height=10)
        self.listbox.pack(fill="both", expand=True, pady=(8, 0))

        self.status = ttk.Label(frm, text="準備完了", relief="sunken", anchor="w")
        self.status.pack(fill="x", pady=(10, 0))

        self._setup_dnd_if_available()
        self.apply_preset()  # initialize from preset selection

    # --- DnD support ---
    def _setup_dnd_if_available(self):
        try:
            from tkinterdnd2 import DND_FILES  # type: ignore
            self.listbox.drop_target_register(DND_FILES)  # type: ignore
            self.listbox.dnd_bind("<<Drop>>", self._on_drop)  # type: ignore
            self.status.config(text="ドラッグ&ドロップ有効(tkinterdnd2)")
        except Exception:
            self.status.config(text="ドラッグ&ドロップ無効(tkinterdnd2未導入): 「ファイル追加…」を使用してください")

    def _on_drop(self, event):
        paths = parse_dnd_files(event.data)
        self.add_files([Path(p) for p in paths])

    # --- Preset logic ---
    def apply_preset(self):
        name = self.var_preset.get()
        preset = PRESETS.get(name)

        if preset is None:
            # manual mode: do not override manual settings
            return

        # Apply preset as "set" operations
        self.var_set_pl.set(True)
        self.var_rm_pl.set(False)
        self.var_pl.set(preset["pagelayout"] or "TwoPageRight")

        if preset.get("direction") is None:
            self.var_set_dir.set(False)
            self.var_rm_dir.set(False)
        else:
            self.var_set_dir.set(True)
            self.var_rm_dir.set(False)
            self.var_dir.set(preset["direction"] or "R2L")

    # --- File list operations ---
    def add_files_dialog(self):
        paths = filedialog.askopenfilenames(
            title="PDFを選択",
            filetypes=[("PDF files", "*.pdf"), ("All files", "*.*")]
        )
        if not paths:
            return
        self.add_files([Path(p) for p in paths])

    def add_files(self, paths: List[Path]):
        added = 0
        for p in paths:
            if not p.exists() or p.is_dir():
                continue
            if p not in self.files:
                self.files.append(p)
                self.listbox.insert("end", str(p))
                added += 1
        self.status.config(text=f"{added} 件追加(合計 {len(self.files)} 件)")

    def clear_files(self):
        self.files.clear()
        self.listbox.delete(0, "end")
        self.status.config(text="リストをクリアしました")

    # --- Build edits from GUI ---
    def _get_edits(self) -> CatalogEdits:
        set_pl = bool(self.var_set_pl.get())
        rm_pl = bool(self.var_rm_pl.get())
        set_dir = bool(self.var_set_dir.get())
        rm_dir = bool(self.var_rm_dir.get())

        # Resolve conflicts: prefer "set"
        if set_pl and rm_pl:
            messagebox.showwarning("注意", "PageLayout は「設定」と「削除」が両方ONです。設定を優先します。")
            rm_pl = False
        if set_dir and rm_dir:
            messagebox.showwarning("注意", "Direction は「設定」と「削除」が両方ONです。設定を優先します。")
            rm_dir = False

        return CatalogEdits(
            set_pagelayout=set_pl,
            pagelayout_value=self.var_pl.get() if set_pl else None,
            set_direction=set_dir,
            direction_value=self.var_dir.get() if set_dir else None,
            remove_pagelayout=rm_pl,
            remove_direction=rm_dir,
        )

    def _output_suffix(self, edits: CatalogEdits) -> str:
        if self.var_suffix_mode.get() == "custom":
            s = self.var_custom_suffix.get().strip()
            if not s:
                s = "_converted"
            if not s.startswith("_"):
                s = "_" + s
            return s
        return safe_suffix(edits)

    # --- Run conversion ---
    def run(self):
        if not self.files:
            messagebox.showinfo("情報", "入力ファイルがありません。ファイルを追加してください。")
            return

        try:
            edits = self._get_edits()
            if not any([edits.set_pagelayout, edits.set_direction, edits.remove_pagelayout, edits.remove_direction]):
                messagebox.showinfo("情報", "変更内容が指定されていません。")
                return

            suffix = self._output_suffix(edits)

            ok = 0
            failed = 0
            errors: List[str] = []

            for p in self.files:
                outp = make_output_path(p, suffix)
                self.status.config(text=f"処理中: {p.name} → {outp.name}")
                self.root.update_idletasks()

                try:
                    process_pdf(p, outp, edits)
                    ok += 1
                except Exception as e:
                    failed += 1
                    errors.append(f"{p.name}: {e}")

            if failed == 0:
                self.status.config(text=f"完了: {ok} 件変換しました")
                messagebox.showinfo("完了", f"{ok} 件変換しました。\n出力は入力と同じフォルダに保存しました。")
            else:
                self.status.config(text=f"完了: 成功 {ok} 件 / 失敗 {failed} 件")
                messagebox.showwarning("一部失敗", "いくつかのファイルで失敗しました:\n\n" + "\n".join(errors))

        except Exception as e:
            messagebox.showerror("エラー", str(e))


def main():
    # Prefer TkinterDnD root if available
    try:
        from tkinterdnd2 import TkinterDnD  # type: ignore
        root = TkinterDnD.Tk()
    except Exception:
        root = tk.Tk()

    # macOS scaling tweak (optional)
    try:
        if sys.platform == "darwin":
            root.tk.call("tk", "scaling", 1.0)
    except Exception:
        pass

    App(root)
    root.mainloop()


if __name__ == "__main__":
    main()