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
PageLayout の TwoPageLeft は「一度に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()