「見積書の後ろに添付資料を付けて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/パスワード付きPDF | 400/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回です。連載のほかの回は次のとおりです(連載の一覧ページ)。
- 【Nuxtで作るPDF管理システム 第0回】全体像とNuxt 4プロジェクトの土台づくり
- 【Nuxtで作るPDF管理システム 第1回】PDFをアップロードして安全に保存する
- 【Nuxtで作るPDF管理システム 第2回】ファイル情報をDBに持ち、一覧を表示する
- 【Nuxtで作るPDF管理システム 第3回】検索・絞り込み・ページングをつける
- 【Nuxtで作るPDF管理システム 第4回】ログイン機能を入れる
- 【Nuxtで作るPDF管理システム 第5回】権限管理と「認可つきダウンロード」
- 【Nuxtで作るPDF管理システム 第6回】ブラウザでPDFをプレビューする
- 【Nuxtで作るPDF管理システム 第7回】PDFの結合と分割(この記事)
- 【Nuxtで作るPDF管理システム 第8回】注釈・スタンプ・署名画像を貼る
- 【Nuxtで作るPDF管理システム 第9回】PDFフォーム(AcroForm)に入力する
- 【Nuxtで作るPDF管理システム 第10回】見積書・請求書をデータから生成する
- 【Nuxtで作るPDF管理システム 第11回】本番運用へ:S3への切り替えとデプロイ










コメント
コメント一覧 (1件)
[…] 前回の第7回:PDFの結合と分割では、pdf-lib で結合・分割を行い、元のPDFを上書きしない版管理を作りました。今回もその仕組みに乗せて、「書き込んだら新しい版」にします。 […]