PDFの圧縮/分解/結合ツール

このツール(PDFTool)は、PDFや画像ファイルをドラッグ&ドロップで操作できる多機能PDFユーティリティです。
主な特徴・機能を簡単にまとめます。


何ができるツール?

  • PDFファイルや画像(JPEG, PNG)をウィンドウにドラッグ&ドロップしてリスト化
  • ファイルの並べ替えや削除が簡単(リスト上で順番を変えたりDeleteキーで削除)
  • 各ボタンでPDFファイルや画像ファイルに対し次の操作ができる:

主な機能

ボタン機能の説明
🔻 圧縮選択したPDFを**軽量化(圧縮)**して別フォルダに保存します。
📎 結合複数のPDFを一つのPDFに結合+圧縮して保存します。
📂 分解1つのPDFをページごとに分割し、それぞれ圧縮したPDFとして保存します。
🖼️→📄 画像PDF選択した画像(jpg/png)を
PDF化して保存します。

使い方

  1. 起動するとウィンドウが表示されます。
  2. PDFや画像ファイルをウィンドウ内にドラッグ&ドロップ → ファイルリストに追加されます。
  3. ファイルリスト内で並べ替えや選択・削除が可能。
  4. 実行したい処理のボタンを押すと、出力先のフォルダまたは保存先を指定し、処理が始まります。

技術的な特徴

  • GUI(ウィンドウ操作)は PyQt5 で作成
  • PDF処理には PyPDF2、画像処理には Pillow(PIL) を利用
  • PDFの圧縮処理は Ghostscript をコマンドラインで呼び出して実行
    (Ghostscriptが必要、Windowsはgswin64cを自動認識)
  • すべての操作がGUIで直感的に可能

代表的なユースケース

  • スキャンした大量のPDFを軽くしたいとき
  • バラバラのPDFや画像を一つのPDFにまとめたいとき
  • 1つのPDFを1ページずつバラして管理したいとき
  • JPEG/PNG画像をPDF化したいとき

注意点

  • PDFの圧縮機能を使うにはGhostscriptのインストールが必要
  • 圧縮・分解・結合は「PDFのみ」対象(画像はPDF変換だけ)

「PDFや画像ファイルの整理・圧縮・変換を直感的な操作で一括でこなしたい」人におすすめのツールです!
シンプル操作なので初心者でも迷いません。

import sys
import os
import subprocess
from PyQt5.QtWidgets import (
    QApplication, QWidget, QVBoxLayout, QListWidget, QPushButton,
    QFileDialog, QGridLayout, QMessageBox, QLabel
)
from PyQt5.QtCore import Qt
from PyPDF2 import PdfMerger, PdfReader, PdfWriter
from PIL import Image

class PDFTool(QWidget):
    def __init__(self):
        super().__init__()
        self.setWindowTitle("PDFツール - ドラッグ&ドロップ対応")
        self.setAcceptDrops(True)
        self.resize(600, 400)

        self.list_widget = QListWidget()
        self.list_widget.setDragDropMode(QListWidget.InternalMove)
        self.list_widget.setSelectionMode(QListWidget.SingleSelection)
        self.list_widget.installEventFilter(self)

        self.label = QLabel("PDFまたは画像をここにドラッグ&ドロップして並び替え可能\n選択してDeleteキーで削除できます")
        self.label.setAlignment(Qt.AlignCenter)

        self.btn_compress = QPushButton("🔻 圧縮")
        self.btn_merge = QPushButton("📎 結合")
        self.btn_split = QPushButton("📂 分解")
        self.btn_image_to_pdf = QPushButton("🖼️→📄 画像PDF")

        self.btn_compress.clicked.connect(self.compress_pdfs)
        self.btn_merge.clicked.connect(self.merge_pdfs)
        self.btn_split.clicked.connect(self.split_pdfs)
        self.btn_image_to_pdf.clicked.connect(self.image_to_pdf_only)

        layout = QVBoxLayout()
        layout.addWidget(self.label)
        layout.addWidget(self.list_widget)

        grid = QGridLayout()
        grid.addWidget(self.btn_compress, 0, 0, 1, 2)
        grid.addWidget(self.btn_merge, 1, 0, 1, 2)
        grid.addWidget(self.btn_split, 2, 0, 1, 2)
        grid.addWidget(self.btn_image_to_pdf, 3, 0, 1, 2)
        layout.addLayout(grid)
        self.setLayout(layout)

    def eventFilter(self, obj, event):
        if obj == self.list_widget and event.type() == event.KeyPress:
            if event.key() == Qt.Key_Delete:
                for item in self.list_widget.selectedItems():
                    self.list_widget.takeItem(self.list_widget.row(item))
                return True
        return super().eventFilter(obj, event)

    def dragEnterEvent(self, event):
        if event.mimeData().hasUrls():
            event.acceptProposedAction()

    def dropEvent(self, event):
        for url in event.mimeData().urls():
            path = url.toLocalFile()
            if path.lower().endswith((".pdf", ".jpg", ".jpeg", ".png")):
                self.list_widget.addItem(path)

    def get_output_dir(self):
        output_dir = QFileDialog.getExistingDirectory(self, "出力フォルダを選択")
        return output_dir if output_dir else None

    def compress_pdf(self, input_path, output_path, quality="ebook"):
        gs_command = "gswin64c" if sys.platform.startswith("win") else "gs"
        cmd = [
            gs_command, "-sDEVICE=pdfwrite", "-dCompatibilityLevel=1.4",
            f"-dPDFSETTINGS=/{quality}", "-dNOPAUSE", "-dQUIET", "-dBATCH",
            f"-sOutputFile={output_path}", input_path
        ]
        try:
            subprocess.run(cmd, check=True, creationflags=subprocess.CREATE_NO_WINDOW if sys.platform.startswith("win") else 0)
            return True
        except FileNotFoundError:
            QMessageBox.critical(self, "Ghostscript エラー", "Ghostscript (gswin64c) が見つかりません。パスが通っているか確認してください。")
            return False
        except Exception as e:
            QMessageBox.critical(self, "圧縮エラー", f"{input_path}\n{e}")
            return False

    def compress_pdfs(self):
        output_dir = self.get_output_dir()
        if not output_dir:
            return
        for i in range(self.list_widget.count()):
            path = self.list_widget.item(i).text()
            if path.lower().endswith(".pdf"):
                name = os.path.splitext(os.path.basename(path))[0] + "_compressed.pdf"
                out_path = os.path.join(output_dir, name)
                self.compress_pdf(path, out_path)
        QMessageBox.information(self, "完了", "圧縮完了")

    def merge_pdfs(self):
        paths = [self.list_widget.item(i).text() for i in range(self.list_widget.count()) if self.list_widget.item(i).text().lower().endswith(".pdf")]
        if not paths:
            return
        save_path, _ = QFileDialog.getSaveFileName(self, "保存先(結合+圧縮)", "", "PDF Files (*.pdf)")
        if not save_path:
            return

        temp_path = save_path + ".temp.pdf"
        merger = PdfMerger()
        for path in paths:
            merger.append(path)
        merger.write(temp_path)
        merger.close()

        success = self.compress_pdf(temp_path, save_path)
        if os.path.exists(temp_path):
            os.remove(temp_path)

        if success:
            QMessageBox.information(self, "完了", f"結合+圧縮完了: {save_path}")
        else:
            QMessageBox.warning(self, "失敗", "圧縮に失敗しました")

    def split_pdfs(self):
        output_dir = self.get_output_dir()
        if not output_dir:
            return
        for i in range(self.list_widget.count()):
            path = self.list_widget.item(i).text()
            if path.lower().endswith(".pdf"):
                base_name = os.path.splitext(os.path.basename(path))[0]
                compressed_path = os.path.join(output_dir, f"{base_name}_compressed.pdf")
                success = self.compress_pdf(path, compressed_path)
                if not success or not os.path.exists(compressed_path):
                    continue
                try:
                    reader = PdfReader(compressed_path)
                    for j, page in enumerate(reader.pages):
                        writer = PdfWriter()
                        writer.add_page(page)
                        name = f"{base_name}_p{j+1}.pdf"
                        out_path = os.path.join(output_dir, name)
                        with open(out_path, "wb") as f:
                            writer.write(f)
                except Exception as e:
                    QMessageBox.warning(self, "分解エラー", f"{path} の分解に失敗しました。\n{e}")
                finally:
                    if os.path.exists(compressed_path):
                        os.remove(compressed_path)
        QMessageBox.information(self, "完了", "圧縮+分解完了")

    def image_to_pdf_only(self):
        output_dir = self.get_output_dir()
        if not output_dir:
            return
        for i in range(self.list_widget.count()):
            path = self.list_widget.item(i).text()
            if path.lower().endswith((".jpg", ".jpeg", ".png")):
                try:
                    image = Image.open(path).convert("RGB")
                    base_name = os.path.splitext(os.path.basename(path))[0]
                    pdf_path = os.path.join(output_dir, base_name + ".pdf")
                    image.save(pdf_path)
                except Exception as e:
                    QMessageBox.warning(self, "画像変換エラー", f"{path} の変換に失敗しました。\n{e}")
        QMessageBox.information(self, "完了", "画像をPDFに変換完了")

if __name__ == '__main__':
    import ctypes
    if sys.platform.startswith("win"):
        ctypes.windll.user32.ShowWindow(ctypes.windll.kernel32.GetConsoleWindow(), 0)

    app = QApplication(sys.argv)
    tool = PDFTool()
    tool.show()
    sys.exit(app.exec_())

コメント

コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です