見出し画像

【無料公開】資料の山をスマートに整理!プロジェクトPDFを一括管理する私の秘密ツール

PDF資料多くない?

仕事で新しいプロジェクトが始まると、企画書や説明資料などがどんどん配られて、あっという間にPDFの山…なんてことありませんか?

データサイエンティストとして日々資料を扱う身としては、PDFをスッキリ整理して必要な情報をすぐに引き出せることが何より重要。
今回は、そんな悩みを解決してくれるPDF結合・編集ツールを紹介します。


なぜこのツールを作ったのか?

提出された資料を一つずつ開いては目次を手作業で作り、別々のファイルをコピー&ペーストしていました。

新しいプロジェクトがスタートするたびに同じ作業…さすがに疲れてしまって(笑)。
そこで、ブラウザだけで使えるPDF編集ツールを自作することに。
複数のPDFをまとめたり、不要なページを削除したり、ページを回転・並べ替えしたり…。
しかも一括生成!
そうすれば、プロジェクトごとにすっきりした一冊の資料集を作れるようになり、後から見返すのもずっとラクになりました。

主な機能とこだわりポイント

  • 一括結合・並べ替え:ドラッグ&ドロップでPDFをポンポン追加。ページサムネイルを見ながら順番を変えられるので、間違えにくいです。

  • 不要ページの削除・複製・回転:余計なページは削除、要所は複製、ページが横向きになっている資料はワンクリックで回転。ちょっとした編集がこれ一つで完結。

  • ページサイズを自動調整:資料によって用紙サイズがバラバラでも、最大サイズに合わせて拡大縮小してくれるので、仕上がりが美しいです。

  • ダークモード&ヘルプ付き:夜遅くの作業も快適。Moonアイコンを押すだけでダークモードに。困ったときの使い方は「?」ボタンから確認できます。

  • NotebookLMとの連携:整理したPDFをNotebookLMに放り込めば、プロジェクト全体の資料を横断的に検索・要約したり、差し替え前後の比較をしたりと、活用の幅が広がります。NotebookLMは複数のドキュメントを読み込んでくれるので、事前にこのツールで一冊にまとめておくと読み込みがスムーズ。

実際に使ってみて良かったこと

  • プロジェクトごとに一つのファイルにまとめることで、関係者と共有するときの手間が激減しました。
    「最新版どれだっけ?」という混乱もなくなります。

  • NotebookLMにまとめて読み込ませるとき、一冊の資料集から質問するだけで概要を掴めるので、レビューや議事録作成が効率的に。
    AIに「この資料からポイントを抽出して」と頼む前処理が数分で終わるのは感動です。

デプロイ方法

GASへのデプロイ&セットアップ手順

1. スクリプトプロジェクトの準備

  1. Google ドライブやスプレッドシートから「拡張機能」→「Apps Script」を開き、新しいスクリプトプロジェクトを作ります 。

  2. エディタで Code.gs と index.html を作成し、先ほど提供したコードをそれぞれ貼り付けて保存します(doGetと savePdfToDrive の関数が含まれていることを確認してください)。

2. ウェブアプリとしてデプロイ

  1. スクリプトエディタ右上の「デプロイ」をクリックし、「新しいデプロイ」を選択 。

  2. 「種類の選択」の横の歯車アイコンから「ウェブアプリ」を選びます 。

  3. 表示されるダイアログで、必要なら説明を記入し、**「実行するユーザー」を「自分」に、「アクセスできるユーザー」を「全員(リンクを知っている人)」**に設定し、「デプロイ」ボタンを押します 。

  4. 初回デプロイでは権限認証が求められるので、「承認を続行」をクリックし 、自分のGoogleアカウントを選択します 。

  5. 「このアプリは確認されていません」と表示された場合は、「詳細」をクリックし、「(プロジェクト名)に移動」を選択してから「許可」を押します 。

  6. 承認が完了すると、ウェブアプリのURLが生成されます。このURLがそのままツールの公開アドレスになります 。ブラウザで開いて動作を確認しましょう。

3. 更新・再デプロイのポイント

  • コードを修正した場合は再度「デプロイ」→「デプロイの管理」から既存のデプロイを編集し、新しいバージョンを選択して保存すればURLはそのままで内容だけ更新されます。

  • 社内専用にしたい場合は、アクセス権を「自分のドメイン内の全員」に設定すると安心です。

ちょっとしたTIPS

  • コード管理はドライブ上で:index.html と Code.gs はGoogleドライブのスクリプトエディタ内に保存されているので、バージョン履歴を活用すれば変更履歴も追いやすいです。

  • アクセス権限の設定に注意:社外の人と共有する場合は「全員」を選びますが、社内利用のみなら「自分のドメイン」を選ぶとセキュリティ面で安心です。

  • 名前を付けて分かりやすく:新しいプロジェクトごとにスクリプト名やデプロイの説明を変えておくと、後から管理しやすくなります。

コード配布

index

<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="UTF-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
  <title>PDF結合・編集ツール</title>

  <!-- pdf-lib と pdf.js の読み込み -->
  <!-- 使用する pdf.js のバージョンは 2.16.105 です -->
  <script src="https://cdnjs.cloudflare.com/ajax/libs/pdf.js/2.16.105/pdf.min.js"></script>
  <script src="https://cdnjs.cloudflare.com/ajax/libs/pdf.js/2.16.105/pdf.worker.min.js"></script>
  <script src="https://cdn.jsdelivr.net/npm/pdf-lib/dist/pdf-lib.min.js"></script>

  <!-- スタイル定義 -->
  <style>
    :root {
      --bg: #f5f7fb;
      --card: #ffffff;
      --text: #1f2937;
      --sub: #6b7280;
      --line: #d8e0ea;
      --primary: #2f80ed;
      --primary-dark: #1c64c7;
      --green: #2fbf71;
      --danger: #d93025;
      --danger-bg: #fff0f0;
      --shadow: 0 8px 28px rgba(15, 23, 42, .10);
    }
    body.dark {
      --bg: #111827;
      --card: #1f2937;
      --text: #f9fafb;
      --sub: #cbd5e1;
      --line: #374151;
      --primary: #60a5fa;
      --primary-dark: #3b82f6;
      --green: #34d399;
      --danger: #f87171;
      --danger-bg: #3b1f1f;
      --shadow: 0 8px 28px rgba(0, 0, 0, .35);
    }
    * {
      box-sizing: border-box;
    }
    body {
      margin: 0;
      font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
      background: var(--bg);
      color: var(--text);
    }
    header {
      position: sticky;
      top: 0;
      z-index: 1000;
      background: linear-gradient(135deg, #0f4c81, #2b8fd8);
      color: #fff;
      padding: 14px 20px;
      box-shadow: 0 4px 16px rgba(0,0,0,.18);
    }
    .header-inner {
      max-width: 1180px;
      margin: 0 auto;
      display: flex;
      justify-content: space-between;
      align-items: center;
      gap: 16px;
    }
    h1 {
      margin: 0;
      font-size: 22px;
      font-weight: 800;
    }
    header p {
      margin: 3px 0 0;
      font-size: 13px;
      opacity: .92;
    }
    .header-actions {
      display: flex;
      gap: 8px;
      align-items: center;
    }
    .circle-btn {
      width: 38px;
      height: 38px;
      border-radius: 999px;
      border: 1px solid rgba(255,255,255,.55);
      background: rgba(255,255,255,.16);
      color: #fff;
      font-weight: 800;
      font-size: 18px;
      cursor: pointer;
    }
    main {
      max-width: 1180px;
      margin: 18px auto;
      padding: 0 14px 44px;
    }
    .toolbar {
      position: sticky;
      top: 78px;
      z-index: 900;
      background: var(--card);
      border: 1px solid var(--line);
      border-radius: 18px;
      box-shadow: var(--shadow);
      padding: 14px;
      display: flex;
      flex-wrap: wrap;
      gap: 10px;
      align-items: center;
      margin-bottom: 16px;
    }
    button,
    label.button {
      border: none;
      border-radius: 12px;
      padding: 10px 14px;
      font-size: 14px;
      font-weight: 750;
      cursor: pointer;
      background: #eaf1f8;
      color: #24536f;
      user-select: none;
    }
    body.dark button,
    body.dark label.button {
      background: #334155;
      color: #e5e7eb;
    }
    button:hover,
    label.button:hover {
      filter: brightness(.97);
    }
    .primary {
      background: var(--primary) !important;
      color: #fff !important;
    }
    .green {
      background: var(--green) !important;
      color: #fff !important;
    }
    .danger {
      background: var(--danger-bg) !important;
      color: var(--danger) !important;
      border: 1px solid rgba(217,48,37,.25);
    }
    .ghost {
      background: transparent !important;
      border: 1px solid var(--line);
      color: var(--text) !important;
    }
    input[type="file"] {
      display: none;
    }
    .file-name-input {
      height: 38px;
      min-width: 240px;
      flex: 1;
      border-radius: 12px;
      border: 1px solid var(--line);
      background: var(--card);
      color: var(--text);
      padding: 0 12px;
      font-weight: 650;
      outline: none;
    }
    .summary {
      display: flex;
      flex-wrap: wrap;
      gap: 10px;
      margin-bottom: 16px;
    }
    .summary-card {
      background: var(--card);
      border: 1px solid var(--line);
      border-radius: 16px;
      box-shadow: var(--shadow);
      padding: 12px 14px;
      min-width: 160px;
    }
    .summary-card .label {
      color: var(--sub);
      font-size: 12px;
      font-weight: 700;
    }
    .summary-card .value {
      margin-top: 4px;
      font-size: 18px;
      font-weight: 850;
    }
    .drop-area {
      border: 3px dashed var(--line);
      border-radius: 24px;
      background: var(--card);
      min-height: 170px;
      display: flex;
      justify-content: center;
      align-items: center;
      text-align: center;
      color: var(--sub);
      padding: 22px;
      margin-bottom: 16px;
    }
    .drop-area.dragover {
      border-color: var(--primary);
      background: rgba(47,128,237,.08);
    }
    .status {
      margin: 12px 0;
      font-weight: 800;
      color: var(--primary-dark);
      min-height: 22px;
    }
    body.dark .status {
      color: #93c5fd;
    }
    .progress-wrap {
      width: 100%;
      height: 10px;
      background: rgba(148,163,184,.25);
      border-radius: 999px;
      overflow: hidden;
      margin: 8px 0 16px;
      display: none;
    }
    .progress-bar {
      height: 100%;
      width: 0%;
      background: var(--primary);
      transition: width .18s ease;
    }
    .page-grid {
      display: grid;
      grid-template-columns: repeat(auto-fill, minmax(155px, 1fr));
      gap: 16px;
    }
    .page-card {
      background: var(--card);
      border: 2px solid transparent;
      border-radius: 18px;
      padding: 10px;
      box-shadow: var(--shadow);
      cursor: grab;
      transition: transform .12s ease, border-color .12s ease, opacity .12s ease;
    }
    .page-card:active {
      cursor: grabbing;
    }
    .page-card.selected {
      border-color: var(--primary);
      transform: translateY(-2px);
    }
    .page-card.dragging {
      opacity: .45;
    }
    .thumb-wrap {
      background: rgba(148,163,184,.18);
      border-radius: 14px;
      height: 192px;
      display: flex;
      align-items: center;
      justify-content: center;
      overflow: hidden;
    }
    .thumb-wrap img {
      max-width: 100%;
      max-height: 100%;
      display: block;
    }
    .page-title {
      margin-top: 8px;
      font-size: 13px;
      font-weight: 800;
      word-break: break-all;
    }
    .page-sub {
      margin-top: 4px;
      font-size: 12px;
      color: var(--sub);
      line-height: 1.4;
    }
    .page-actions {
      display: flex;
      gap: 6px;
      margin-top: 9px;
    }
    .page-actions button {
      flex: 1;
      padding: 7px 6px;
      font-size: 12px;
      border-radius: 9px;
    }
    .modal-bg {
      display: none;
      position: fixed;
      inset: 0;
      z-index: 3000;
      background: rgba(0,0,0,.45);
      align-items: center;
      justify-content: center;
      padding: 20px;
    }
    .modal {
      width: min(640px, 100%);
      max-height: 84vh;
      overflow: auto;
      background: var(--card);
      color: var(--text);
      border-radius: 22px;
      box-shadow: 0 20px 60px rgba(0,0,0,.32);
      padding: 24px;
    }
    .modal h2 {
      margin: 0 0 12px;
    }
    .modal li {
      margin-bottom: 10px;
      line-height: 1.6;
    }
    .modal-close {
      width: 100%;
      margin-top: 12px;
      background: var(--primary) !important;
      color: #fff !important;
    }
    a {
      color: var(--primary-dark);
      font-weight: 800;
    }
    body.dark a {
      color: #93c5fd;
    }
    .empty-note {
      color: var(--sub);
      font-weight: 700;
      text-align: center;
      padding: 18px;
    }
    @media (max-width: 720px) {
      h1 {
        font-size: 18px;
      }
      header p {
        display: none;
      }
      .toolbar {
        top: 66px;
      }
      .file-name-input {
        min-width: 100%;
      }
      .page-grid {
        grid-template-columns: repeat(2, minmax(0, 1fr));
        gap: 12px;
      }
      .thumb-wrap {
        height: 170px;
      }
      button,
      label.button {
        padding: 9px 11px;
        font-size: 13px;
      }
    }
  </style>
</head>

<body>
  <header>
    <div class="header-inner">
      <div>
        <h1>PDF結合・編集ツール</h1>
        <p>ページ単位の並べ替え・削除・複製・回転・書き出し</p>
      </div>
      <div class="header-actions">
        <button class="circle-btn" onclick="toggleDarkMode()">☾</button>
        <button class="circle-btn" onclick="openHelp()">?</button>
      </div>
    </div>
  </header>

  <main>
    <!-- ツールバー -->
    <div class="toolbar">
      <label class="button green">
        📁 PDF追加
        <input type="file" id="fileInput" accept="application/pdf" multiple />
      </label>

      <button onclick="moveSelectedPage(-1)">↑ 前へ</button>
      <button onclick="moveSelectedPage(1)">↓ 後ろへ</button>
      <button onclick="rotateSelectedPage()">↻ 回転</button>
      <button onclick="duplicateSelectedPage()">⧉ 複製</button>
      <button onclick="deleteSelectedPage()" class="danger">🗑 削除</button>

      <input id="outputFileName" class="file-name-input" value="merged.pdf" />

      <button onclick="downloadMergedPdf()" class="primary">⬇ 書き出し</button>
      <button onclick="saveMergedPdfToDrive()" class="primary">💾 Drive保存</button>
      <button onclick="clearAll()" class="ghost">全クリア</button>
    </div>

    <!-- サマリー -->
    <div class="summary">
      <div class="summary-card">
        <div class="label">読み込みPDF</div>
        <div class="value" id="pdfCount">0件</div>
      </div>
      <div class="summary-card">
        <div class="label">編集後ページ</div>
        <div class="value" id="pageCount">0ページ</div>
      </div>
      <div class="summary-card">
        <div class="label">元ファイル合計</div>
        <div class="value" id="totalSize">0 MB</div>
      </div>
    </div>

    <!-- ドロップエリア -->
    <div class="drop-area" id="dropArea">
      <div>
        <div style="font-size:44px;">📄</div>
        <p><b>ここにPDFをドラッグ&ドロップ</b></p>
        <p>追加後、ページ単位で並べ替え・削除・回転・複製できます。</p>
      </div>
    </div>

    <!-- ステータスとプログレスバー -->
    <div class="status" id="status"></div>
    <div class="progress-wrap" id="progressWrap">
      <div class="progress-bar" id="progressBar"></div>
    </div>

    <div id="emptyNote" class="empty-note">まだPDFが追加されていません。</div>
    <div class="page-grid" id="pageGrid"></div>
  </main>

  <!-- ヘルプモーダル -->
  <div class="modal-bg" id="helpModal">
    <div class="modal">
      <h2>使い方</h2>
      <ol>
        <li>「PDF追加」またはドラッグ&ドロップでPDFを追加します。</li>
        <li>各ページがサムネイルで表示されます。</li>
        <li>ページをクリックすると選択できます。</li>
        <li>ドラッグ&ドロップでページ順を変更できます。</li>
        <li>ボタン操作でも、前後移動・回転・複製・削除ができます。</li>
        <li>ファイル名を入力して「書き出し」または「Drive保存」を押します。</li>
      </ol>
      <p>※ 暗号化PDF、破損PDF、大容量PDFは読み込めない場合があります。</p>
      <button class="modal-close" onclick="closeHelp()">閉じる</button>
    </div>
  </div>

  <!-- ロジック -->
  <script>
    // pdf.js の worker 設定
    pdfjsLib.GlobalWorkerOptions.workerSrc = 'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/2.16.105/pdf.worker.min.js';

    // データストア
    const pdfStore = [];
    const pages = [];
    let selectedPageId = null;
    let draggingPageId = null;

    // DOM参照
    const fileInput = document.getElementById('fileInput');
    const dropArea = document.getElementById('dropArea');
    const pageGrid = document.getElementById('pageGrid');
    const statusEl = document.getElementById('status');
    const progressWrap = document.getElementById('progressWrap');
    const progressBar = document.getElementById('progressBar');
    const emptyNote = document.getElementById('emptyNote');

    // ファイル追加イベント
    fileInput.addEventListener('change', async (e) => {
      await addFiles(e.target.files);
      fileInput.value = '';
    });

    // ドラッグ&ドロップイベント
    dropArea.addEventListener('dragover', (e) => {
      e.preventDefault();
      dropArea.classList.add('dragover');
    });
    dropArea.addEventListener('dragleave', () => {
      dropArea.classList.remove('dragover');
    });
    dropArea.addEventListener('drop', async (e) => {
      e.preventDefault();
      dropArea.classList.remove('dragover');
      await addFiles(e.dataTransfer.files);
    });

    // UUID生成
    function uuid() {
      if (crypto && crypto.randomUUID) return crypto.randomUUID();
      return 'id-' + Date.now() + '-' + Math.random().toString(16).slice(2);
    }

    // PDF読み込みとサムネイル生成
    async function addFiles(fileList) {
      const files = [...fileList].filter(file =>
        file.type === 'application/pdf' || file.name.toLowerCase().endsWith('.pdf')
      );
      if (files.length === 0) {
        setStatus('PDFファイルを選択してください。');
        return;
      }
      showProgress(0);
      setStatus('PDFを読み込み中...');
      for (let i = 0; i < files.length; i++) {
        try {
          await addSinglePdf(files[i], i, files.length);
        } catch (e) {
          console.error(e);
          setStatus(`読み込み失敗:${files[i].name} / ${e.message}`);
        }
      }
      hideProgress();
      setStatus('読み込みが完了しました。');
      render();
    }

    async function addSinglePdf(file, fileIndex, fileTotal) {
      const buffer = await file.arrayBuffer();
      const sourceId = uuid();
      const originalBytes = new Uint8Array(buffer.slice(0));
      const previewBytes = new Uint8Array(buffer.slice(0));
      pdfStore.push({
        sourceId,
        fileName: file.name,
        size: file.size,
        bytes: originalBytes
      });
      const pdf = await pdfjsLib.getDocument({ data: previewBytes }).promise;
      for (let pageNo = 1; pageNo <= pdf.numPages; pageNo++) {
        const thumbnail = await createThumbnail(pdf, pageNo);
        pages.push({
          pageId: uuid(),
          sourceId,
          pageNo,
          rotation: 0,
          thumbnail
        });
        const loadedRate = ((fileIndex + pageNo / pdf.numPages) / fileTotal) * 100;
        showProgress(loadedRate);
        render();
      }
    }

    async function createThumbnail(pdf, pageNo) {
      const page = await pdf.getPage(pageNo);
      const viewport = page.getViewport({ scale: 0.38 });
      const canvas = document.createElement('canvas');
      const ctx = canvas.getContext('2d');
      canvas.width = viewport.width;
      canvas.height = viewport.height;
      await page.render({ canvasContext: ctx, viewport }).promise;
      return canvas.toDataURL('image/png');
    }

    // レンダリング処理
    function render() {
      renderSummary();
      renderPages();
    }

    function renderSummary() {
      document.getElementById('pdfCount').textContent = `${pdfStore.length}件`;
      document.getElementById('pageCount').textContent = `${pages.length}ページ`;
      const totalBytes = pdfStore.reduce((sum, item) => sum + item.size, 0);
      document.getElementById('totalSize').textContent = formatSize(totalBytes);
      emptyNote.style.display = pages.length === 0 ? 'block' : 'none';
    }

    function renderPages() {
      pageGrid.innerHTML = '';
      pages.forEach((page, index) => {
        const pdfInfo = pdfStore.find(x => x.sourceId === page.sourceId);
        const card = document.createElement('div');
        card.className = 'page-card' + (selectedPageId === page.pageId ? ' selected' : '');
        card.draggable = true;
        card.dataset.pageId = page.pageId;
        card.addEventListener('click', () => {
          selectedPageId = page.pageId;
          renderPages();
        });
        card.addEventListener('dragstart', () => {
          draggingPageId = page.pageId;
          card.classList.add('dragging');
        });
        card.addEventListener('dragend', () => {
          draggingPageId = null;
          card.classList.remove('dragging');
        });
        card.addEventListener('dragover', (e) => {
          e.preventDefault();
        });
        card.addEventListener('drop', (e) => {
          e.preventDefault();
          reorderByDrag(draggingPageId, page.pageId);
        });
        card.innerHTML = `
          <div class="thumb-wrap">
            <img src="${page.thumbnail}" style="transform: rotate(${page.rotation}deg);" />
          </div>
          <div class="page-title">${index + 1}. ${escapeHtml(pdfInfo.fileName)}</div>
          <div class="page-sub">元PDF ${page.pageNo}ページ目 / 回転 ${page.rotation}°</div>
          <div class="page-actions">
            <button onclick="event.stopPropagation(); movePageByIndex(${index}, -1)">↑</button>
            <button onclick="event.stopPropagation(); movePageByIndex(${index}, 1)">↓</button>
            <button onclick="event.stopPropagation(); rotatePage('${page.pageId}')">↻</button>
            <button onclick="event.stopPropagation(); duplicatePage('${page.pageId}')">複製</button>
            <button class="danger" onclick="event.stopPropagation(); deletePage('${page.pageId}')">削除</button>
          </div>
        `;
        pageGrid.appendChild(card);
      });
    }

    // 並べ替えドラッグ
    function reorderByDrag(fromPageId, toPageId) {
      if (!fromPageId || !toPageId || fromPageId === toPageId) return;
      const fromIndex = pages.findIndex(p => p.pageId === fromPageId);
      const toIndex = pages.findIndex(p => p.pageId === toPageId);
      if (fromIndex < 0 || toIndex < 0) return;
      const [moved] = pages.splice(fromIndex, 1);
      pages.splice(toIndex, 0, moved);
      selectedPageId = moved.pageId;
      render();
    }

    // 移動関連
    function getSelectedIndex() {
      return pages.findIndex(p => p.pageId === selectedPageId);
    }
    function moveSelectedPage(direction) {
      const index = getSelectedIndex();
      if (index < 0) {
        setStatus('移動するページを選択してください。');
        return;
      }
      movePageByIndex(index, direction);
    }
    function movePageByIndex(index, direction) {
      const newIndex = index + direction;
      if (newIndex < 0 || newIndex >= pages.length) return;
      [pages[index], pages[newIndex]] = [pages[newIndex], pages[index]];
      selectedPageId = pages[newIndex].pageId;
      render();
    }

    // 回転処理
    function rotateSelectedPage() {
      const index = getSelectedIndex();
      if (index < 0) {
        setStatus('回転するページを選択してください。');
        return;
      }
      rotatePage(pages[index].pageId);
    }
    function rotatePage(pageId) {
      const page = pages.find(p => p.pageId === pageId);
      if (!page) return;
      page.rotation = (page.rotation + 90) % 360;
      selectedPageId = pageId;
      render();
    }

    // 複製処理
    function duplicateSelectedPage() {
      const index = getSelectedIndex();
      if (index < 0) {
        setStatus('複製するページを選択してください。');
        return;
      }
      duplicatePage(pages[index].pageId);
    }
    function duplicatePage(pageId) {
      const index = pages.findIndex(p => p.pageId === pageId);
      if (index < 0) return;
      const original = pages[index];
      const copied = { ...original, pageId: uuid() };
      pages.splice(index + 1, 0, copied);
      selectedPageId = copied.pageId;
      render();
    }

    // 削除処理
    function deleteSelectedPage() {
      const index = getSelectedIndex();
      if (index < 0) {
        setStatus('削除するページを選択してください。');
        return;
      }
      deletePage(pages[index].pageId);
    }
    function deletePage(pageId) {
      const index = pages.findIndex(p => p.pageId === pageId);
      if (index < 0) return;
      pages.splice(index, 1);
      selectedPageId = null;
      setStatus('ページを削除しました。');
      render();
    }

    /**
     * PDFの結合処理。embedPage() を使用して各ページを埋め込み、drawPage() へ渡す方式。
     * すべてのページサイズを統一し、回転も反映します。
     */
    async function buildPdfBytes() {
      if (pages.length === 0) {
        throw new Error('PDFページを追加してください。');
      }
      const mergedPdf = await PDFLib.PDFDocument.create();
      const docCache = new Map();
      const sizeCache = new Map();
      let maxW = 0;
      let maxH = 0;
      // 1. 回転後サイズを計算して最大幅・高さを求める
      for (const pageItem of pages) {
        let info = sizeCache.get(pageItem.pageId);
        if (!info) {
          let srcDoc = docCache.get(pageItem.sourceId);
          if (!srcDoc) {
            const pdfInfo = pdfStore.find(x => x.sourceId === pageItem.sourceId);
            srcDoc = await PDFLib.PDFDocument.load(pdfInfo.bytes.slice());
            docCache.set(pageItem.sourceId, srcDoc);
          }
          const srcPage = srcDoc.getPages()[pageItem.pageNo - 1];
          const w = srcPage.getWidth();
          const h = srcPage.getHeight();
          const rotated = (pageItem.rotation / 90) % 2 !== 0;
          const rotW = rotated ? h : w;
          const rotH = rotated ? w : h;
          info = { rotW, rotH };
          sizeCache.set(pageItem.pageId, info);
        }
        maxW = Math.max(maxW, info.rotW);
        maxH = Math.max(maxH, info.rotH);
      }
      // 2. embedPage() を使って統一サイズに貼り付け
      for (let i = 0; i < pages.length; i++) {
        const pageItem = pages[i];
        let srcDoc = docCache.get(pageItem.sourceId);
        if (!srcDoc) {
          const pdfInfo = pdfStore.find(x => x.sourceId === pageItem.sourceId);
          srcDoc = await PDFLib.PDFDocument.load(pdfInfo.bytes.slice());
          docCache.set(pageItem.sourceId, srcDoc);
        }
        const srcPage = srcDoc.getPages()[pageItem.pageNo - 1];
        const embeddedPage = await mergedPdf.embedPage(srcPage);
        const info = sizeCache.get(pageItem.pageId);
        const rotW = info.rotW;
        const rotH = info.rotH;
        const scale = Math.min(maxW / rotW, maxH / rotH);
        const newPage = mergedPdf.addPage([maxW, maxH]);
        const xOffset = (maxW - rotW * scale) / 2;
        const yOffset = (maxH - rotH * scale) / 2;
        newPage.drawPage(embeddedPage, {
          x: xOffset,
          y: yOffset,
          xScale: scale,
          yScale: scale,
          rotate: PDFLib.degrees(pageItem.rotation || 0)
        });
        showProgress(((i + 1) / pages.length) * 100);
      }
      return await mergedPdf.save();
    }

    // 書き出し処理
    async function downloadMergedPdf() {
      try {
        const bytes = await buildPdfBytes();
        const fileName = normalizeFileName(document.getElementById('outputFileName').value);
        const blob = new Blob([bytes], { type: 'application/pdf' });
        const url = URL.createObjectURL(blob);
        const a = document.createElement('a');
        a.href = url;
        a.download = fileName;
        document.body.appendChild(a);
        a.click();
        a.remove();
        URL.revokeObjectURL(url);
        setStatus('PDFを書き出しました。');
      } catch (e) {
        hideProgress();
        setStatus('書き出し失敗:' + e.message);
      }
    }

    // Drive保存処理
    async function saveMergedPdfToDrive() {
      try {
        const bytes = await buildPdfBytes();
        const fileName = normalizeFileName(document.getElementById('outputFileName').value);
        const base64 = uint8ToBase64(bytes);
        setStatus('Google Driveに保存中...');
        google.script.run
          .withSuccessHandler(result => {
            setStatus('');
            statusEl.innerHTML = `Driveに保存しました:<a href="${result.url}" target="_blank">${escapeHtml(result.name)}</a>`;
          })
          .withFailureHandler(error => {
            setStatus('Drive保存失敗:' + error.message);
          })
          .savePdfToDrive(base64, fileName);
      } catch (e) {
        hideProgress();
        setStatus('Drive保存失敗:' + e.message);
      }
    }

    // ユーティリティ群
    function uint8ToBase64(uint8Array) {
      let binary = '';
      const chunkSize = 0x8000;
      for (let i = 0; i < uint8Array.length; i += chunkSize) {
        const chunk = uint8Array.subarray(i, i + chunkSize);
        binary += String.fromCharCode.apply(null, chunk);
      }
      return btoa(binary);
    }
    function normalizeFileName(name) {
      let fileName = (name || '').trim();
      if (!fileName) {
        fileName = `merged_${formatDate(new Date())}.pdf`;
      }
      if (!fileName.toLowerCase().endsWith('.pdf')) {
        fileName += '.pdf';
      }
      return fileName;
    }
    function clearAll() {
      if (!confirm('追加したPDFと編集内容をすべて削除しますか?')) return;
      pdfStore.length = 0;
      pages.length = 0;
      selectedPageId = null;
      draggingPageId = null;
      setStatus('クリアしました。');
      render();
    }
    function showProgress(percent) {
      progressWrap.style.display = 'block';
      progressBar.style.width = `${Math.max(0, Math.min(100, percent))}%`;
    }
    function hideProgress() {
      setTimeout(() => {
        progressWrap.style.display = 'none';
        progressBar.style.width = '0%';
      }, 350);
    }
    function setStatus(message) {
      statusEl.textContent = message;
    }
    function formatSize(bytes) {
      if (!bytes) return '0 MB';
      return `${(bytes / 1024 / 1024).toFixed(2)} MB`;
    }
    function formatDate(date) {
      const y = date.getFullYear();
      const m = String(date.getMonth() + 1).padStart(2, '0');
      const d = String(date.getDate()).padStart(2, '0');
      const h = String(date.getHours()).padStart(2, '0');
      const min = String(date.getMinutes()).padStart(2, '0');
      return `${y}${m}${d}_${h}${min}`;
    }
    function escapeHtml(str) {
      return String(str)
        .replaceAll('&', '&amp;')
        .replaceAll('<', '&lt;')
        .replaceAll('>', '&gt;')
        .replaceAll('"', '&quot;')
        .replaceAll("'", '&#039;');
    }
    // モーダル操作とテーマ切り替え
    function openHelp() {
      document.getElementById('helpModal').style.display = 'flex';
    }
    function closeHelp() {
      document.getElementById('helpModal').style.display = 'none';
    }
    function toggleDarkMode() {
      document.body.classList.toggle('dark');
    }
    // 初期描画
    render();
  </script>
</body>
</html>

GAS

function doGet() {
  return HtmlService
    .createHtmlOutputFromFile('index')
    .setTitle('PDF結合・編集ツール')
    .setXFrameOptionsMode(HtmlService.XFrameOptionsMode.ALLOWALL);
}

function savePdfToDrive(base64Data, fileName) {
  const bytes = Utilities.base64Decode(base64Data);
  const blob = Utilities.newBlob(bytes, 'application/pdf', fileName);
  const file = DriveApp.createFile(blob);

  return {
    id: file.getId(),
    name: file.getName(),
    url: file.getUrl()
  };
}

おわりに

忙しい毎日の中でも、ちょっとした工夫で作業はグッと楽になります。このPDF編集ツールで資料の山をサクッと整理し、NotebookLMなどのAIツールを活用することで、資料管理がもっとスマートに。
もし同じような悩みを抱えている方がいたら、ぜひお試しください!

#PDF整理
#資料管理
#データサイエンティスト
#働く女性
#仕事効率化
#プロジェクト管理
#NotebookLM活用
#OLライフ
#プログラミング



いいなと思ったら応援しよう!

データ分析|コピペで動くAIアプリ|[ゆき] よろしければ応援お願いします! いただいたチップはクリエイターとしての活動費に使わせていただきます!