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 というファイル名を付けて保存して使用してください。
"""
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: 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]
set_direction: bool
direction_value: Optional[str]
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
def process_pdf(in_pdf: Path, out_pdf: Path, edits: CatalogEdits) -> None:
try:
import pikepdf
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))
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
class App:
def __init__(self, root: tk.Tk):
self.root = root
root.title("PDF 表示設定(PageLayout / Direction)")
root.geometry("820x820")
self.files: List[Path] = []
self.var_preset = tk.StringVar(value="設定しない(手動指定)")
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")
self.var_custom_suffix = tk.StringVar(value="_converted")
frm = ttk.Frame(root, padding=12)
frm.pack(fill="both", expand=True)
preset_frame = ttk.LabelFrame(frm, text="表示プリセット(おすすめ)", padding=10)
preset_frame.pack(fill="x")
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)
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))
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))
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()
def _setup_dnd_if_available(self):
try:
from tkinterdnd2 import DND_FILES
self.listbox.drop_target_register(DND_FILES)
self.listbox.dnd_bind("<<Drop>>", self._on_drop)
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])
def apply_preset(self):
name = self.var_preset.get()
preset = PRESETS.get(name)
if preset is None:
return
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")
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="リストをクリアしました")
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())
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)
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():
try:
from tkinterdnd2 import TkinterDnD
root = TkinterDnD.Tk()
except Exception:
root = tk.Tk()
try:
if sys.platform == "darwin":
root.tk.call("tk", "scaling", 1.0)
except Exception:
pass
App(root)
root.mainloop()
if __name__ == "__main__":
main()