MENU

問い合わせ


    【Nuxtで作るPDF管理システム 第7回】PDFの結合と分割

    「見積書の後ろに添付資料を付けて1つのPDFにしたい」「スキャンでまとめて取り込んだ書類を、1件ずつのPDFに分けたい」。PDFを扱う業務では、結合と分割の作業が日常的に発生します。今回は、システムの画面からPDFを結合・分割できるようにし、あわせて「編集しても元のPDFを上書きせず、新しい版として残す」版管理の仕組みを導入します。

    前回の第6回:ブラウザでPDFをプレビューするでは、pdfjs-dist でPDFを画面上に描画できるようにしました。今回からいよいよ、PDFの中身を変更する機能に入ります。

    「結合や分割なら無料のツールでもできますよね。システムに組み込むと何がうれしいの?」

    結論から言うと、システムに組み込む価値は「誰が・いつ・何をしたかが残り、元に戻せること」にあります。手元のツールで作業すると、どれが最新版か分からなくなったり、元のファイルを上書きしてしまったりしがちです。この回では、PDFの編集ライブラリ pdf-lib(執筆時点で 1 系)をサーバー側で使い、結合は「新しい版」、分割は「新しいファイル」として保存します。

    目次

    結合・分割の設計:元のPDFは上書きしない

    今回できるようになること

    操作内容保存のされ方
    結合表示中のPDFの末尾に、選んだ他のPDF(最大9件)のページを順番に追加表示中のファイルの新しい版。元の版・追加に使ったファイルは変わらない
    分割ページ範囲(例:1-2, 3-5)ごとに切り出す範囲ごとに新しいファイル(元の名前_p1-2.pdf など)。元のファイルは変わらない
    版の履歴版ごとの操作内容・作成者・日時を表示過去の版もダウンロードできる

    結合は「元の文書に資料を足す」操作なので同じファイルの新しい版に、分割は「別の文書として切り出す」操作なので新しいファイルにしました。どちらも、元のPDFの中身は一切書き換えません。

    表示はブラウザ、変更はサーバー

    処理場所理由
    プレビュー(第6回)ブラウザ(pdfjs-dist)画面に描くのはブラウザが得意
    結合・分割(今回)サーバー(pdf-lib)権限を確認したうえで確定させ、結果を確実に保存するため

    pdf-lib はブラウザでも動きますが、ブラウザで編集した結果をそのまま受け取ると、「本当に正しい操作の結果か」をサーバーで確かめられません。画面からは「どのファイルを・どのページで」という指示だけを送り、PDFの加工はサーバーで行います。

    pdf-lib を選んでいる理由とリスク

    pdf-lib は、PDFの結合・分割、文字や画像の書き込み、フォーム入力、新規作成までを1つのライブラリで扱えます。一方で、npm で公開されている最新版の公開日は2021年11月で、それ以降のリリースがありません。パスワード付き(暗号化)PDFの編集にも対応していません。本連載では学習のしやすさを優先して pdf-lib を使い続け、代替(更新が続いているフォーク版など)は最終回付近で触れます。

    📰 出典:pdf-lib 公式サイト

    版を記録するテーブルを追加する

    server/db/schema.ts(追加部分)

    /** 版の作られ方 */
    export const VERSION_OPERATIONS = ['upload', 'merge', 'split'] as const
    export type VersionOperation = (typeof VERSION_OPERATIONS)[number]
    
    /**
     * ファイルの版(履歴)。編集しても元のPDFは上書きせず、新しい版を追加する。
     * ファイルを消すと版の行も消える(本体の削除は API 側で行う)
     */
    export const fileVersions = sqliteTable('file_versions', {
      id: integer('id').primaryKey({ autoIncrement: true }),
      fileId: text('file_id').notNull().references(() => files.id, { onDelete: 'cascade' }),
      /** 版の番号(ファイルごとに 1, 2, 3 …) */
      versionNo: integer('version_no').notNull(),
      /** この版のPDF本体の保存キー */
      storageKey: text('storage_key').notNull().unique(),
      size: integer('size').notNull(),
      pageCount: integer('page_count').notNull(),
      /** どの操作で作られたか */
      operation: text('operation', { enum: VERSION_OPERATIONS }).notNull(),
      /** 操作の内容(例:「結合:請求書.pdf(3ページ)を末尾に追加」) */
      note: text('note'),
      /** 版を作った利用者(users.id) */
      createdBy: text('created_by'),
      createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
    }, (table) => [
      uniqueIndex('file_versions_file_version_unique').on(table.fileId, table.versionNo),
    ])

    files テーブルには currentVersion(現在の版の番号)を追加し、storageKey・size・pageCount は「現在の版」の値を持つことにしました。一覧や検索は今までどおり files だけを見ればよく、履歴が必要なときだけ file_versions を読みます。

    マイグレーションには、既存のファイルを「第1版」として登録する1文を手で追記しています。

    server/db/migrations/0004_versions.sql(追記部分)

    -- 既存のファイルを「第1版(upload)」として登録する(手動で追記)
    INSERT INTO `file_versions` (`file_id`, `version_no`, `storage_key`, `size`, `page_count`, `operation`, `note`, `created_by`, `created_at`)
    SELECT `id`, 1, `storage_key`, `size`, `page_count`, 'upload', NULL, `uploaded_by`, `created_at` FROM `files`;

    アップロードAPIも、ファイルの登録と同時に第1版の行を作るように変えました(createFileWithFirstVersion という関数にまとめ、分割でも使います)。

    pdf-lib で結合・分割する

    server/utils/pdf/pages.ts(抜粋)

    import { PDFDocument } from 'pdf-lib'
    
    /** pdf-lib で読み込む。パスワード付き・壊れたPDFは 400 */
    async function loadForEdit(data: Uint8Array): Promise<PDFDocument> {
      try {
        // ignoreEncryption を付けない:暗号化PDFは EncryptedPDFError になる(編集対象外)
        return await PDFDocument.load(data, { updateMetadata: false })
      } catch {
        throw createError({ statusCode: 400, statusMessage: 'このPDFは編集できません(パスワード付き、または壊れている可能性があります)' })
      }
    }
    
    /** 複数のPDFを、渡した順に1つにまとめる */
    export async function mergePdfs(sources: Uint8Array[]): Promise<Uint8Array> {
      const merged = await PDFDocument.create()
      for (const data of sources) {
        const src = await loadForEdit(data)
        const pages = await merged.copyPages(src, src.getPageIndices())
        for (const page of pages) merged.addPage(page)
      }
      return merged.save()
    }
    
    /** 指定したページ(0 始まり)だけを取り出した新しいPDFを作る */
    export async function extractPages(data: Uint8Array, pageIndices: number[]): Promise<Uint8Array> {
      const src = await loadForEdit(data)
      const out = await PDFDocument.create()
      const pages = await out.copyPages(src, pageIndices)
      for (const page of pages) out.addPage(page)
      return out.save()
    }

    結合も分割も、やっていることは同じです。空のPDFを作り(PDFDocument.create())、元のPDFから必要なページを copyPages でコピーして addPage で追加します。ページは0から数えるので、画面で入力された「2ページ目」は 1 になります。

    📰 出典:pdf-lib API ドキュメント PDFDocument(copyPages)

    ページ範囲の入力を解釈する

    分割のページ範囲は、利用者が自由に入力する文字列です。書式と範囲を厳しく確認します。

    server/utils/pdf/pages.ts(抜粋)

    /**
     * ページ範囲の指定(例:"1-2, 5, 7-9")を解釈し、範囲ごとの 0 始まりのページ番号の配列にする。
     * 書式の誤り・ページ数を超える指定は 400。
     */
    export function parsePageRanges(input: string, pageCount: number): number[][] {
      const parts = input.split(/[,、]/).map((s) => s.trim()).filter((s) => s !== '')
      if (parts.length === 0 || parts.length > MAX_RANGES) {
        throw createError({ statusCode: 400, statusMessage: `ページ範囲を1〜${MAX_RANGES}個指定してください(例:1-2, 5)` })
      }
      return parts.map((part) => {
        const m = /^(\d+)(?:\s*-\s*(\d+))?$/.exec(part)
        const start = m ? Number(m[1]) : Number.NaN
        const end = m?.[2] !== undefined ? Number(m[2]) : start
        if (!m || start < 1 || end < start || end > pageCount) {
          throw createError({ statusCode: 400, statusMessage: `ページ範囲「${part}」が正しくありません(1〜${pageCount}ページ)` })
        }
        return Array.from({ length: end - start + 1 }, (_, i) => start - 1 + i)
      })
    }

    区切りは半角カンマと読点(、)の両方を受け付け、範囲の数は20個までにしました。すべての範囲を先に検証してから処理を始めるので、「途中まで分割してからエラー」になりにくくしています。

    新しい版として保存する

    server/utils/file-versions.ts(抜粋)

    export async function addVersion(params: {
      fileId: string
      data: Uint8Array
      pageCount: number
      operation: VersionOperation
      note: string
      userId: string
    }): Promise<{ versionNo: number }> {
      const storageKey = `${randomUUID()}.pdf`
      const storage = useFileStorage()
      await storage.put(storageKey, Buffer.from(params.data), 'application/pdf')
      try {
        return useDb().transaction((tx) => {
          // 版番号は「これまでの最大 + 1」。better-sqlite3 のトランザクションは直列に実行される
          const last = tx.select({ value: max(fileVersions.versionNo) }).from(fileVersions)
            .where(eq(fileVersions.fileId, params.fileId)).get()?.value ?? 0
          const versionNo = last + 1
          tx.insert(fileVersions).values({ /* fileId, versionNo, storageKey, size, pageCount, operation, note, createdBy, createdAt */ }).run()
          tx.update(files).set({
            storageKey,
            size: params.data.length,
            pageCount: params.pageCount,
            currentVersion: versionNo,
          }).where(eq(files.id, params.fileId)).run()
          return { versionNo }
        })
      } catch (err) {
        await storage.delete(storageKey)
        throw err
      }
    }

    新しい版のPDFは、新しいUUIDの名前で保存します。元の版の本体には触れないので、過去の版はいつでも取り出せます。DBの更新に失敗したら、保存したばかりの本体を消して不整合を残さないのは、第1回・第2回のアップロードと同じ考え方です。

    同じファイルの読み込みは readCurrentPdf(node:stream/consumers の buffer() でストレージのストリームを読み切る)、編集できるかの確認は getEditableFile(存在しなければ 404、パスワード付きなら 400)にまとめました。

    結合APIと分割API

    server/api/files/[id]/merge.post.ts(抜粋)

    export default defineEventHandler(async (event) => {
      const user = await requireRole(event, 'editor')
      const id = await getFileIdParam(event)
      const body = await readValidatedBody(event, (b) => mergeBodySchema.safeParse(b))
      if (!body.success) {
        throw createError({ statusCode: 400, statusMessage: '結合するファイルを1〜9件指定してください' })
      }
      const { fileIds } = body.data
      if (fileIds.includes(id)) {
        throw createError({ statusCode: 400, statusMessage: '結合先と同じファイルは指定できません' })
      }
    
      // 1. 対象のファイルを取得(どれか1つでも無ければ 404、パスワード付きなら 400)
      const base = getEditableFile(id)
      // …(fileIds がすべて存在するかの確認は省略)
      const others = fileIds.map((fid) => getEditableFile(fid))
    
      // 2. 現在の版の中身を読み込み、pdf-lib で結合
      const sources = await Promise.all([base, ...others].map((f) => readCurrentPdf(f)))
      const merged = await mergePdfs(sources)
      assertResultSize(merged)
    
      // 3. 新しい版として保存
      const pageCount = [base, ...others].reduce((sum, f) => sum + f.pageCount, 0)
      const note = `結合:${others.map((f) => `${f.originalName}(${f.pageCount}ページ)`).join('、')} を末尾に追加`
      const { versionNo } = await addVersion({ fileId: id, data: merged, pageCount, operation: 'merge', note, userId: user.id })
    
      return { id, versionNo, pageCount }
    })

    server/api/files/[id]/split.post.ts(抜粋)

    export default defineEventHandler(async (event) => {
      const user = await requireRole(event, 'editor')
      const id = await getFileIdParam(event)
      // …(ranges の検証は省略)
      const source = getEditableFile(id)
      const ranges = parsePageRanges(body.data.ranges, source.pageCount)
      const data = await readCurrentPdf(source)
      const baseName = source.originalName.replace(/\.pdf$/i, '')
      // 元ファイルのタグを引き継ぐ
      const tagNames = getTagNames(id)
    
      const created: { id: string, originalName: string, pageCount: number }[] = []
      for (const pages of ranges) {
        const out = await extractPages(data, pages)
        assertResultSize(out)
        const first = pages[0]! + 1
        const last = pages[pages.length - 1]! + 1
        const label = first === last ? `p${first}` : `p${first}-${last}`
        const originalName = sanitizeFileName(`${baseName}_${label}.pdf`)
        const { id: newId } = await createFileWithFirstVersion({
          originalName,
          data: out,
          pageCount: pages.length,
          isEncrypted: false,
          operation: 'split',
          note: `分割:${source.originalName}(第${source.currentVersion}版)の ${label} から作成`,
          userId: user.id,
          tagNames,
        })
        created.push({ id: newId, originalName, pageCount: pages.length })
      }
    
      setResponseStatus(event, 201)
      return { sourceId: id, files: created }
    })
    • 権限:どちらも editor 以上(第5回の requireRole)です。削除と違い、他の人が登録したファイルも編集できる設計にしました。元の版が残るため、誤操作しても取り戻せるからです。
    • 処理後のサイズ:結合するとファイルが大きくなるため、アップロードと同じ上限(20MB)を超えたら保存しません(assertResultSize)。
    • 分割したファイルの記録:新しいファイルの第1版に「どのファイルの第何版の、どのページから作ったか」を残します。登録者は分割した人、タグは元のファイルから引き継ぎます。

    過去の版のダウンロードと、削除時の後片付け

    第5回のダウンロードAPIに ?version=N を追加し、過去の版も同じ認可チェックでダウンロードできるようにしました。過去の版のファイル名には _v1 のように版番号を付けます。存在しない版は 404、数字でない指定は 400 です。

    ファイルを削除するAPIは、file_versions から全版の保存キーを集めて、すべての本体を消すように変えました。現在の版だけを消すと、過去の版の本体がストレージに残り続けてしまいます。

    画面:結合・分割パネルと版の履歴

    詳細画面(app/pages/files/[id].vue)に、editor 以上の人にだけ表示する操作パネル(app/components/PdfPageTools.vue)と、版の履歴の表を追加しました。

    app/pages/files/[id].vue(追加部分)

    <script setup lang="ts">
    // プレビューはダウンロードAPIを inline 指定で読む(認可はダウンロードと同じ)。
    // 版番号を URL に含めるので、新しい版ができると PdfViewer が読み込み直す
    const previewUrl = computed(() => `/api/files/${id.value}/download?inline=1&version=${file.value?.currentVersion ?? 1}`)
    
    const canEdit = computed(() => !!user.value && hasRole(user.value.role, 'editor') && !file.value?.isEncrypted)
    </script>
    
    <template>
      <PdfViewer :src="previewUrl" />
      <PdfPageTools v-if="canEdit" :file-id="file.id" :page-count="file.pageCount" @merged="refresh()" />
      <!-- 版の履歴:版・操作・ページ数・作成者・日時・その版のダウンロードリンク(省略) -->
    </template>

    結合が終わるとパネルが merged イベントを出し、詳細画面はファイル情報を取り直します。現在の版の番号が変わるとプレビューのURLも変わるため、第6回のビューア部品が新しい版を読み込み直します。パネルの中身(ファイル名で検索して結合するファイルを順番に選ぶリスト、ページ範囲の入力欄)は、サンプルコードを参照してください。

    動作確認の方法

    npm run db:migrate   # 0004_versions.sql(file_versions、既存ファイルを第1版に)
    npm run build
    node .output/server/index.mjs

    第6回までのDBをマイグレーションしたうえで、curl と自動操作のブラウザ(ヘッドレスの Chrome)で確認しました。

    操作結果
    マイグレーション後の既存ファイルすべて第1版(upload)として登録、保存キーも一致
    3ページのPDFに1ページのPDFを結合第2版(4ページ)。さらに2件を結合すると第3版(8ページ)
    第1版をダウンロードアップロードした元のPDFとバイト単位で一致。ファイル名は 見積書A_v1.pdf
    追加に使ったファイル版もページ数も変わらない
    自分自身を結合/0件/存在しないID/パスワード付きPDF400/400/404/400
    viewer が結合・分割403
    8ページのPDFを「2-3」で分割2ページの新しいファイル 見積書A_p2-3.pdf
    「1-4、5, 6-8」で分割4・1・3ページの3ファイル。タグを引き継ぎ、履歴に元ファイルと版が残る
    「0-1」「3-2」「1-9」「a」、21個の範囲いずれも 400
    分割しても元のファイル第3版・8ページのまま
    3つの版を持つファイルを削除204。3つの版の本体がすべてストレージから消える
    画面:検索して1件追加 → 結合「第2版を作成しました(5ページ)」、プレビューが 1 / 5 ページに更新、履歴に第2版(現在)
    画面:「1, 2-4」で分割2件のファイルへのリンクが表示される

    つまずきやすい点・pdf-lib の限界

    • しおり(目次)が引き継がれない:copyPages はページをコピーする機能で、文書全体に付いている「しおり」は対象外です。筆者の環境で、しおりが2つあるPDFを結合した結果、しおりは0件になりました。
    • フォームの入力欄が入力欄として残らない:入力欄(AcroForm)を持つPDFを結合したところ、ページ上の入力欄の見た目の部品は残りましたが、文書としての入力欄の一覧は空になりました。フォーム付きPDFの扱いは第9回で改めて取り上げます。
    • パスワード付きPDFは編集できない:pdf-lib は暗号化PDFの編集に対応していません。登録時に記録した isEncrypted で先に断り、画面では編集パネル自体を出さないようにしています。
    • メモリの使用量:結合・分割はPDF全体をメモリに読み込みます。20MBのPDFを9件結合するような操作が同時に重なると、サーバーのメモリを大きく使います。本番では同時実行数の制限や、処理を順番待ちにする仕組み(キュー)を検討します。
    • 分割は範囲ごとに順番に保存する:範囲の書式は先にまとめて検証していますが、保存の途中で障害が起きると、一部のファイルだけが作られた状態になり得ます。
    • 版が増えるとストレージも増える:全版の本体を残すため、編集が多いほど容量を使います。何世代まで残すか、古い版をいつ消すかは運用で決める必要があります。

    発注者向けメモ:「PDFの編集」は、対象のPDFによって難易度が変わる

    結合・分割は、PDF編集の中では比較的シンプルな機能です。それでも、対象のPDFによっては追加の対応が必要になります。

    確認すること影響
    パスワード付きPDFが含まれるか今回の構成では編集できない。解除の運用か、別の仕組みが必要
    しおり・フォーム・電子署名付きのPDFを編集するか結合・分割で失われる、または署名が無効になる。引き継ぐなら追加の実装・検証が必要
    1ファイルの大きさと、同時に作業する人数サーバーの性能・構成(処理の順番待ちなど)に影響する
    過去の版をどこまで残すかストレージの容量と費用、削除の運用に影響する

    特に、電子署名(電子契約サービスなどで付けた署名)が入ったPDFは、ページを加工すると署名の検証ができなくなるのが一般的です。「署名済みの契約書は編集対象外にする」といったルールを先に決めておくと安全です。

    • 編集してよいPDFの種類を決める:取引先から届いたPDF、署名済みの書類など、編集してはいけないものを洗い出します。
    • 「元に戻せる」ことの要件化:誤操作したときに、誰がどうやって元の版に戻すのか。今回は過去の版をダウンロードできるところまでで、「この版に戻す」ボタンは作っていません。
    • サンプルPDFでの確認:実際に結合・分割したいPDFを数種類渡し、結果を確認してもらいます。

    打ち合わせでは、次のように聞いてみてください。

    • 「結合・分割をしたとき、元のPDFは残りますか?過去の版に戻せますか?」
    • 「しおりやフォームの入力欄があるPDFを結合すると、それらはどうなりますか?」
    • 「パスワード付きのPDFや、電子署名付きのPDFを編集しようとした場合はどうなりますか?」
    • 「大きなPDFを何人かが同時に結合したとき、サーバーは耐えられますか?」
    • 「使っているPDFライブラリの更新状況と、更新が止まった場合の対応方針は?」

    まとめと次回予告

    第7回では、PDFの結合と分割、それを支える版管理を実装しました。

    • 変更はサーバーの pdf-lib で行い、画面からは「どのファイルの・どのページか」だけを送る
    • 結合は同じファイルの新しい版、分割は新しいファイルとして保存し、元のPDFは上書きしない
    • file_versions で版ごとの本体・操作内容・作成者を記録し、過去の版もダウンロードできる
    • しおり・フォームは copyPages では引き継がれない。暗号化PDFは編集対象外

    次回は「注釈・スタンプ・署名画像を貼る」です。第6回のプレビュー上でクリックした位置を、PDFの座標に変換し、日本語のテキスト・承認印・手書き署名の画像をPDFに書き込みます。書き込んだ結果は、今回作った「新しい版」として保存します。

    この連載の記事一覧

    この記事は連載「Nuxtで作るPDF管理システム」の1回です。連載のほかの回は次のとおりです(連載の一覧ページ)。

    システム制作・運用・保守のお問い合わせはこちら


      よかったらシェアしてね!
      • URLをコピーしました!
      • URLをコピーしました!

      この記事を書いた人

      株式会社THIRD HERO代表取締役 朝野貴朗
      Webシステム開発を中心に、toC向けサービスサイトの運営、ツール開発などを行ってまいりました。

      コメント

      コメント一覧 (1件)

      目次