ブラウザ完結型PDFエディタの仕組み【pdf.js + pdf-lib + fabric.js 技術解説】

Editriq 編集部
この記事でわかること:
  • ブラウザだけで PDF を編集するアーキテクチャの全体像
  • pdf.js / pdf-lib / fabric.js がそれぞれ何を担当しているか
  • クラウド型と比べたセキュリティとパフォーマンスのトレードオフ
  • 実装時にハマりやすいメモリ・墨消し・フォント埋め込みの落とし穴

「PDF 編集ツールはサーバー側で処理するのが常識」——この前提が 2020 年代後半になって根本から覆りつつあります
ブラウザの WebAssembly 対応とメモリ管理の進歩、そして pdf.js / pdf-lib / fabric.js といった成熟したオープンソースライブラリの登場により、クライアントサイドだけで実用的な PDF 編集 が可能になりました。

本記事では、実際に稼働している Editriq のアーキテクチャを例に、ブラウザ完結型 PDF エディタの内部構造 をコード例付きで解説します。
対象読者は以下のような開発者です。

  • 機密文書を扱う業務アプリで PDF 編集機能を実装したい人
  • 既存のクラウド型 PDF SaaS の代替として自社プロダクトに組み込みたい人
  • pdf.js / pdf-lib のドキュメントを読んだけれど、組み合わせ方がイメージできなかった人
  • クライアントサイドのメモリ管理・パフォーマンス最適化に興味がある人

なぜサーバーを使わずブラウザで完結させるのか

まず、クラウド型と比較したときのトレードオフを整理しておきます。

観点 ブラウザ完結型 クラウド型(Smallpdf 等)
プライバシー ✅ ファイルがネットワークに出ない ⚠️ サーバーに一時保存される
処理速度 ✅ ファイル転送時間がない ⚠️ アップロード/ダウンロード時間が加算
インフラコスト ✅ サーバー負荷ゼロ ❌ 同時アクセス数に比例
ファイルサイズ ❌ ブラウザメモリに依存(~数百 MB) ✅ ほぼ無制限
OS/ブラウザ依存 ⚠️ モダンブラウザ必須 ✅ どこでも動く
バックエンド開発 ✅ 不要 ❌ 必要
オフライン対応 ✅ PWA 化で可能 ❌ 不可

プライバシーとインフラコストの両方で優位性があります。特に「個人情報を含む PDF を扱う業務アプリ」では、クラウドアップロードが実質的に禁止されるケースも多く、ブラウザ完結型が事実上の選択肢になります。

全体アーキテクチャ

Editriq の構成を簡略化すると以下のようになります。

┌─────────────────────────────────────────────────────┐
│                     Browser                        │
│                                                     │
│  ┌───────────┐   ┌────────────┐   ┌─────────────┐  │
│  │  pdf.js   │──→│ fabric.js  │──→│   pdf-lib   │  │
│  │ (表示)    │   │ (UI 操作)  │   │ (書き出し)  │  │
│  └───────────┘   └────────────┘   └─────────────┘  │
│       ↑                                   ↓        │
│       │      ArrayBuffer / Blob          │        │
│       │                                   │        │
│       └────── File Input / Drop ←─────────┘        │
│                                                     │
│                 ✘ No Network I/O                   │
└─────────────────────────────────────────────────────┘

各ライブラリの役割:

  1. pdf.js — Mozilla 製、PDF のパース・レンダリング・テキスト抽出を担当
  2. fabric.js — Canvas 上でオブジェクトを編集するための WYSIWYG UI レイヤー
  3. pdf-lib — 編集後の状態を PDF 形式で書き出す

この 3 つはそれぞれ独立したライブラリで、組み合わせ方は実装者次第です。
Editriq はこの 3 つを連携させる「接着剤」の部分を独自実装しています。

ステップ 1: pdf.js で PDF を読み込む

ユーザーが PDF をドロップすると、まず pdf.js で読み込みます。

import * as pdfjsLib from 'pdfjs-dist';

// Worker は別ファイルで読み込む(重要)
pdfjsLib.GlobalWorkerOptions.workerSrc = '/pdf.worker.min.js';

async function loadPdf(file) {
  const arrayBuffer = await file.arrayBuffer();
  const pdfDoc = await pdfjsLib.getDocument({ data: arrayBuffer }).promise;

  const pages = [];
  for (let i = 1; i <= pdfDoc.numPages; i++) {
    const page = await pdfDoc.getPage(i);
    pages.push(page);
  }
  return { pdfDoc, pages };
}

ポイント:

  • Worker を別ファイルで読み込む: pdf.js の重い処理は Web Worker に逃がすため、workerSrc を設定します。これがないと UI スレッドがブロックされ、大きな PDF でフリーズします
  • ArrayBuffer で渡す: File オブジェクトを直接渡すより、ArrayBuffer の方が挙動が安定します
  • ページは遅延読み込み: pdfDoc.getPage(i) は呼び出し時にパースされるため、100 ページ一気に読み込まず、表示中のページ周辺だけ取得するのが最適です

ステップ 2: Canvas にレンダリング

pdf.js は PDF ページを Canvas に描画できます。これをユーザーに見せる表示層です。

async function renderPageToCanvas(page, canvas, scale = 1.5) {
  const viewport = page.getViewport({ scale });
  canvas.width = viewport.width;
  canvas.height = viewport.height;

  const context = canvas.getContext('2d');
  await page.render({
    canvasContext: context,
    viewport,
  }).promise;
}

パフォーマンスチューニング:

  • scale の選び方: devicePixelRatio を掛けると Retina / 4K ディスプレイで綺麗に表示されますが、メモリ使用量が 4 倍に跳ねます。通常は 1.5 〜 2.0 が現実的
  • OffscreenCanvas: Chrome 等では Worker スレッドで Canvas 描画できる OffscreenCanvas API が使えます。UI スレッドを解放してスクロール応答性が大幅に向上します

ステップ 3: fabric.js で編集 UI を構築

ユーザーが画面上でテキスト追加・画像挿入・墨消し・図形描画するためのレイヤーが fabric.js です。
pdf.js がレンダリングした Canvas の上に透明な Canvas を重ね、ここに編集オブジェクトを描きます。

import { fabric } from 'fabric';

function setupEditor(pdfCanvas) {
  // pdf.js の Canvas の上に fabric の Canvas を重ねる
  const editorCanvas = document.createElement('canvas');
  editorCanvas.width = pdfCanvas.width;
  editorCanvas.height = pdfCanvas.height;
  editorCanvas.style.position = 'absolute';
  editorCanvas.style.top = '0';
  editorCanvas.style.left = '0';
  pdfCanvas.parentElement.appendChild(editorCanvas);

  const fabricCanvas = new fabric.Canvas(editorCanvas, {
    selection: true,
  });

  return fabricCanvas;
}

// 墨消し矩形の追加
function addRedactionRect(canvas, x, y, width, height) {
  const rect = new fabric.Rect({
    left: x,
    top: y,
    width,
    height,
    fill: 'black',
    selectable: true,
    data: { type: 'redaction' },  // 後で pdf-lib に渡すためのタグ
  });
  canvas.add(rect);
}

// 画像の追加(透過 PNG も対応)
function addImage(canvas, imageFile, x, y) {
  const reader = new FileReader();
  reader.onload = (e) => {
    fabric.Image.fromURL(e.target.result, (img) => {
      img.set({ left: x, top: y });
      canvas.add(img);
    });
  };
  reader.readAsDataURL(imageFile);
}

ポイント:

  • 二重 Canvas レイヤー: pdf.js で描画した元の PDF と、fabric.js で編集可能なオブジェクトを分離して重ねるのが基本パターン
  • data 属性で型を識別: 後で pdf-lib に渡すとき、オブジェクトの種類(墨消し / 画像 / テキスト)で分岐するためにカスタムタグを付けておく
  • Undo / Redo: fabric.js の canvas.toJSON() / canvas.loadFromJSON() でシリアライズし、状態スタックに保存

ステップ 4: pdf-lib で書き出し

ユーザーが「保存」を押したら、fabric.js のオブジェクトを pdf-lib に渡して PDF として書き出します。

import { PDFDocument, rgb } from 'pdf-lib';

async function exportPdf(originalArrayBuffer, fabricObjects) {
  const pdfDoc = await PDFDocument.load(originalArrayBuffer);
  const page = pdfDoc.getPage(0);

  for (const obj of fabricObjects) {
    if (obj.data?.type === 'redaction') {
      // 墨消し: 矩形を描く + 該当範囲のテキストを削除
      page.drawRectangle({
        x: obj.left,
        y: page.getHeight() - obj.top - obj.height,  // 座標系反転
        width: obj.width,
        height: obj.height,
        color: rgb(0, 0, 0),
      });
      await removeTextInRegion(page, obj);  // 後述
    } else if (obj.type === 'image') {
      const imgBytes = await fetch(obj.src).then(r => r.arrayBuffer());
      const embedded = obj.src.includes('png')
        ? await pdfDoc.embedPng(imgBytes)
        : await pdfDoc.embedJpg(imgBytes);
      page.drawImage(embedded, {
        x: obj.left,
        y: page.getHeight() - obj.top - obj.height,
        width: obj.width,
        height: obj.height,
      });
    }
  }

  const pdfBytes = await pdfDoc.save();
  return pdfBytes;
}

重要な注意点:

  • 座標系の反転: fabric.js は左上原点、pdf-lib は左下原点。Y 座標を反転する処理を忘れるとオブジェクトが上下逆に配置されます
  • 画像埋め込み: embedPng / embedJpg は内部で画像データを最適化します。元画像が大きいと PDF も膨らむため、事前に圧縮するのが推奨
  • フォント埋め込み: テキスト追加時は pdfDoc.embedFont() で日本語フォントを埋め込む必要があります。標準では Helvetica 等の Latin フォントしか使えません

ステップ 5: 墨消しを「本当に」安全にする

「矩形を上に重ねるだけ」の墨消しは PDF 内部のテキストレイヤーが残るため、コピー&ペーストで復元できてしまいます。
Editriq では以下の二段階処理で実現しています。

async function removeTextInRegion(page, region) {
  // pdf-lib の低レベル API で content stream を操作
  const contentStream = page.node.Contents();
  // ... テキスト描画コマンド(Tj / TJ)のうち該当範囲のものを削除
  // 詳細は pdf-lib の内部構造ドキュメント参照
}

プロダクション実装のポイント:

  1. テキストレイヤー(Tj / TJ オペレータ)から該当範囲の文字を削除
  2. その上に黒(または白)の矩形を描画
  3. フォーム(AcroForm)にも同じ範囲の入力欄があれば削除
  4. メタデータ(Title / Author / Keywords)に機密情報が残っていないか確認

詳細は PDFの墨消しを無料でする方法【完全ガイド】 でも利用者向けに解説しています。

メモリ管理の落とし穴

ブラウザ環境ではヒープメモリ上限が最大の制約です。

対策 1: ArrayBuffer を即座に解放

// BAD: 参照を保持し続けて GC を妨害
this.originalBuffer = arrayBuffer;

// GOOD: 使い終わったら null を代入して明示的に解放
const pdfDoc = await pdfjsLib.getDocument({ data: arrayBuffer }).promise;
arrayBuffer = null;

対策 2: ページごとの Canvas を使い回す

全ページの Canvas を保持すると、100 ページの PDF で 100 枚の Canvas(各 10MB 程度)= 1GB 消費することもあります。
表示中の前後 3 ページ分だけ Canvas を保持し、それ以外は破棄するのが実装パターンです。

対策 3: transferToImageBitmap

OffscreenCanvas の transferToImageBitmap() を使うと、編集中ではない過去ページを軽量な ImageBitmap として保持できます。

WebAssembly という選択肢

pdf.js / pdf-lib はすべて JavaScript 実装ですが、ネイティブ実装を WebAssembly で持ち込む選択肢もあります。

  • MuPDF.js (Artifex) — MuPDF ライブラリの WASM ポート、パフォーマンスに優れる
  • PDFium WASM — Google の PDFium エンジンの実験的 WASM ビルド

WebAssembly 版は JavaScript 版より 3〜5 倍高速ですが、バンドルサイズが大きくなり(数 MB 〜)、ライセンスも確認が必要です。
Editriq は現時点で純 JS 実装(pdf.js + pdf-lib)を採用していますが、将来的に WASM 化する計画もあります。

セキュリティ: なぜブラウザ完結型が安全なのか

サーバー型と比較したセキュリティモデルを整理します。

サーバー型のリスク

  1. アップロード時の中間者攻撃(HTTPS で軽減されるが、経路上のログ取得リスクは残る)
  2. サーバー側の一時ファイルが GC されるまで残る
  3. 処理サーバーのログに Object URL が残る
  4. 運営者の内部犯行リスク

ブラウザ完結型のリスク

  1. ~~アップロード時の中間者攻撃~~ → そもそも送信しない
  2. ~~サーバー側の一時ファイル~~ → 存在しない
  3. ~~運営者の内部犯行~~ → アクセスできない
  4. クライアント側のメモリダンプから抽出される可能性 → 物理アクセスが必要なので現実的脅威ではない

脅威モデルが根本的に変わるのが最大のメリットです。契約書や医療記録を扱う業務アプリでは、この差が決定的です。

コード全体を試したい方へ

本記事で解説した原則に従った実装例は Editriq で実際に動作しています。
ブラウザの開発者ツールで ネットワークタブを開いたまま PDF を読み込み、編集、保存してみてください。
通信が一切発生していないことが確認できるはずです。

これこそがブラウザ完結型の本質的価値です。

まとめ: ブラウザで PDF を扱う時代の常識

pdf.js + pdf-lib + fabric.js の 3 つを組み合わせれば、サーバーなしで本格的な PDF エディタが実装できます。
メモリ管理・墨消し実装・フォント埋め込みといった落とし穴はありますが、プロダクションで動かせるレベルのものがモダンブラウザの機能だけで構築可能です。

プライバシーを重視する業務アプリや、サーバーコストを抑えたい個人開発で、ぜひこのアーキテクチャを検討してみてください。

関連記事:

今すぐ試す

Editriq は本記事で解説したアーキテクチャで実際に稼働しているブラウザ完結型 PDF エディタです。ソースコードの一部は MIT ライセンスで公開中。

Editriq の実機で挙動を確認する →

無料 / 登録不要 / ブラウザ内で完結