1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

React + pdf-lib + Canvas API で完全クライアントサイドのPDF/画像ツールサイトを2つ作った

1
Posted at

はじめに

「PDFを編集したいけど、ファイルを知らないサーバーにアップロードするのは不安...」

この問題を解決するために、ファイルを一切サーバーに送信しないPDF編集サイトと画像編集サイトを作りました。

chrome_TyKAE2iMsj.png

chrome_dEvgDsBbX8.png

すべての処理がブラウザ内のJavaScriptで完結します。この記事では両サイトの技術的な実装について紹介します。

何ができるか

PDF Tools(8ツール)

ツール 説明 主要ライブラリ
Split PDFを分割 pdf-lib, JSZip
Merge 複数PDFを結合 pdf-lib
Replace/Insert ページ差替・挿入 pdf-lib
Compress 圧縮 + グレースケール変換 pdfjs-dist, pdf-lib
Excel to PDF Excel変換 ExcelJS, jsPDF, html2canvas
Image ↔ PDF 画像⇄PDF双方向変換 pdf-lib, pdfjs-dist, JSZip
Sign PDF署名 pdfjs-dist, pdf-lib
Add Page Numbers ページ番号追加 pdf-lib

eMsnww4NBL.png

Image Tools(10ツール)

ツール 説明 技術
Compress 画像圧縮 Canvas API
Resize リサイズ Canvas API
Convert 7形式変換(BMP/GIF/ICO含む) Canvas + カスタムエンコーダ
Crop ドラッグ選択で切り抜き Canvas API
Rotate/Flip 回転・反転(自由角度対応) Canvas API
Watermark テキスト/画像透かし(一括処理対応) Canvas API
Filter 6種フィルター CSS Filters
Background Removal AI背景除去 @imgly/background-removal (ONNX)
Metadata EXIF + AI生成情報表示 exifr + カスタムPNGパーサー
Upscale AI高解像度化(2x/4x) TensorFlow.js + ESRGAN

技術スタック

PDF Tools — React 19 + Vite 7
├── pdf-lib          ... PDF操作(結合、分割、署名、ページ番号)
├── pdfjs-dist       ... PDF描画(圧縮、画像変換で使用)
├── ExcelJS          ... Excelファイル解析
├── jsPDF            ... Excel→PDF変換
├── html2canvas      ... HTML→Canvas変換
├── JSZip            ... ZIP生成
└── file-saver       ... ダウンロード処理

Image Tools — React 19 + Vite 7
├── Canvas API       ... 圧縮、リサイズ、切り抜き、回転、透かし
├── CSS Filters      ... フィルター処理
├── @imgly/background-removal  ... AI背景除去(ONNX Runtime)
├── @tensorflow/tfjs ... AI高解像度化エンジン
├── upscaler + @upscalerjs/esrgan-medium  ... ESRGANモデル
├── exifr            ... EXIF情報解析
├── JSZip            ... ZIP生成(一括処理時)
└── file-saver       ... ダウンロード処理

こだわった点

1. コードスプリッティングで初回ロードを91%削減

PDF Tools の最初のビルドでは初回ロードが 2,775KB ありました。これを 248KB まで削減しました。

React.lazy() による動的インポート

// 各ツールを遅延読み込み — 選択したタブだけロードされる
const SplitMode = lazy(() => import('./components/SplitMode'));
const MergeMode = lazy(() => import('./components/MergeMode'));
const CompressMode = lazy(() => import('./components/CompressMode'));
// ...

Vite の manualChunks でライブラリを分離

PDF Tools:

// vite.config.js
manualChunks: {
  'pdfjs': ['pdfjs-dist'],
  'excel-pdf': ['exceljs', 'jspdf', 'html2canvas'],
  'pdf-lib': ['pdf-lib'],
}

Image Tools ではAI系ライブラリが特に巨大なので、さらに細かく分離:

// vite.config.js
manualChunks: {
  'react-vendor': ['react', 'react-dom'],
  'jszip': ['jszip'],
  'bg-removal': ['@imgly/background-removal'],   // ONNX Runtime含む
  'exifr': ['exifr'],
  'tfjs': ['@tensorflow/tfjs'],                   // ~2MB
  'upscaler': ['upscaler', '@upscalerjs/esrgan-slim', '@upscalerjs/esrgan-medium'],
}

これにより、例えば「圧縮」だけ使うユーザーにはTensorFlow.jsのチャンクはロードされません。

2. Canvas APIだけで実現する画像処理

Image Tools の画像処理は、外部ライブラリを使わず Canvas API だけで実装しています。

圧縮 — quality パラメータの活用

const canvas = document.createElement('canvas');
canvas.width = img.naturalWidth;
canvas.height = img.naturalHeight;
const ctx = canvas.getContext('2d');
ctx.drawImage(img, 0, 0);

// quality: 0〜1 で JPEG/WebP の圧縮率を制御
canvas.toBlob((blob) => {
  // 圧縮後のblobを取得
}, 'image/jpeg', 0.7); // 70% 品質

回転 — 自由角度対応の座標計算

90度回転は単純ですが、自由角度回転ではキャンバスサイズの再計算が必要です:

// 自由角度回転 — 画像が切れないようにキャンバスを拡張
const rad = angle * Math.PI / 180;
const sin = Math.abs(Math.sin(rad));
const cos = Math.abs(Math.cos(rad));

// 回転後に必要なキャンバスサイズ(対角線を考慮)
canvas.width = Math.ceil(w * cos + h * sin);
canvas.height = Math.ceil(w * sin + h * cos);

ctx.translate(canvas.width / 2, canvas.height / 2);
ctx.rotate(rad);
ctx.drawImage(img, -w / 2, -h / 2);

フィルター — CSS Filters をCanvasに適用

const filterString = `brightness(${filters.brightness}%)
  contrast(${filters.contrast}%)
  saturate(${filters.saturate}%)
  blur(${filters.blur}px)
  sepia(${filters.sepia}%)
  grayscale(${filters.grayscale}%)`;

ctx.filter = filterString; // drawImage時に自動適用
ctx.drawImage(img, 0, 0);

ctx.filter を使えば、ピクセル単位の操作をせずにCSS Filtersと同じ効果を画像ファイルに焼き込めます。

透かし — バッチ処理時の相対サイズ計算

複数画像に一括で透かしを入れる場合、画像サイズが異なるため、フォントサイズを**画像の短辺に対する%**で指定できるようにしました:

// 短辺の10%をフォントサイズにする
const shortSide = Math.min(canvas.width, canvas.height);
const fontSize = Math.round(shortSide * fontSizePct / 100);

// タイル状に透かしを敷き詰める
for (let y = -diag; y < diag; y += gap) {
  for (let x = -diag; x < diag; x += gap) {
    ctx.fillText(text, x, y);
  }
}

3. カスタムバイナリエンコーダで7形式変換

Canvas API の toBlob() は JPEG/PNG/WebP/AVIF に対応していますが、BMP・GIF・ICO には対応していません。これらは自作のバイナリエンコーダで実装しました。

BMP エンコーダ

function canvasToBmp(canvas) {
  const ctx = canvas.getContext('2d');
  const { width, height } = canvas;
  const imageData = ctx.getImageData(0, 0, width, height);

  // BMPは行サイズが4バイト境界にパディングされる
  const rowSize = Math.ceil((width * 3) / 4) * 4;

  // BMPファイルヘッダ(14B) + BITMAPINFOHEADER(40B) + ピクセルデータ
  const fileSize = 54 + rowSize * height;
  const buffer = new ArrayBuffer(fileSize);

  // ピクセルデータ: RGBA → BGR変換 + 上下反転
  for (let y = height - 1; y >= 0; y--) {
    for (let x = 0; x < width; x++) {
      const srcIdx = (y * width + x) * 4;
      bytes[dstIdx]     = data[srcIdx + 2]; // B
      bytes[dstIdx + 1] = data[srcIdx + 1]; // G
      bytes[dstIdx + 2] = data[srcIdx];     // R
    }
  }
  return new Blob([buffer], { type: 'image/bmp' });
}

BMPはRGB→BGRの並び替えと、ピクセルデータが下から上に格納される仕様があり、手動で変換が必要です。

GIF エンコーダ(LZW圧縮付き)

// 1. 色の量子化(256色以内にする)
// 2. パレット構築
// 3. LZW圧縮でエンコード
// 4. GIF89aフォーマットで出力

const lzwData = lzwEncode(indexedPixels, minCodeSize);
// 辞書が4096エントリを超えたらリセット

GIFの最大の課題は 256色制限。元画像の色を量子化してパレットにマッピングする処理を自前で実装しています。

4. AI機能もブラウザ内で完結

背景除去 — ONNX Runtimeでブラウザ推論

const { removeBackground } = await import('@imgly/background-removal');

const blob = await removeBackground(file, {
  progress: (key, current, total) => {
    const pct = Math.round((current / total) * 100);
    setStatusMsg(`処理中: ${pct}%`);
  },
});
// → 透過PNGが返る

chrome_wX1TSrKXFt.png

初回使用時に約50MBのONNXモデルがダウンロードされますが、ブラウザキャッシュに保存されるため2回目以降は即座に処理が始まります。

高解像度化 — TensorFlow.js + ESRGAN

const tf = await import('@tensorflow/tfjs');
const Upscaler = (await import('upscaler')).default;

// 2x or 4x でモデルを切り替え
const model = scale === 4
  ? (await import('@upscalerjs/esrgan-medium/4x')).default
  : (await import('@upscalerjs/esrgan-medium/2x')).default;

const upscaler = new Upscaler({ model });
const result = await upscaler.upscale(img, {
  patchSize: 64,  // 64pxタイルで分割処理(メモリ節約)
  padding: 4,     // タイル間のオーバーラップ(継ぎ目防止)
});

ポイント: patchSize: 64 で画像を小さなタイルに分割して推論することで、GPUメモリ不足を回避しています。大きな画像でもOOMにならずに処理できます。

5. PNGメタデータからAI生成情報を検出

MetadataツールではEXIF情報だけでなく、Stable Diffusion・ComfyUI・NovelAI の生成パラメータも表示できます。

function parsePngTextChunks(buffer) {
  // PNGシグネチャを検証
  const sig = [137, 80, 78, 71, 13, 10, 26, 10];

  // チャンクを順に読み取る
  while (offset < buffer.byteLength - 12) {
    const length = view.getUint32(offset);
    const type = String.fromCharCode(...typeBytes);

    if (type === 'tEXt') {
      // テキストチャンク: keyword + null + text
    } else if (type === 'iTXt') {
      // 国際テキストチャンク: keyword + null + 圧縮フラグ + 言語タグ + text
    }
    offset += 12 + length; // 4(長さ) + 4(タイプ) + データ + 4(CRC)
  }
}

function detectAiMetadata(chunks) {
  for (const chunk of chunks) {
    // "parameters" → Stable Diffusion (AUTOMATIC1111)
    // "prompt" or "workflow" (JSON) → ComfyUI
    // "comment", "source" → NovelAI
  }
}

PNGファイルの tEXt / iTXt チャンクにAI生成ツールがメタデータを埋め込むので、これをバイナリレベルで解析しています。

6. 5言語対応を独自i18nで実装

react-i18next などのライブラリを使わず、シンプルな独自実装にしました。

// i18n.js — 翻訳辞書(約1,000行)
const translations = {
  en: {
    'app.title': 'PDF Tools',
    'split.button': 'Split & Download ZIP',
    // ...
  },
  ja: {
    'app.title': 'PDF ツール',
    'split.button': '分割してZIPダウンロード',
    // ...
  },
  // vi, id, zh も同様
};
// LanguageContext.jsx — プレースホルダー置換対応
const t = (key, ...args) => {
  const str = translations[lang]?.[key] || translations.en[key] || key;
  return str.replace(/\{(\d+)\}/g, (_, i) => args[Number(i)] ?? '');
};

// 使用例: t('bgRemove.progress', 75) → "処理中: 75%"

ブラウザ言語の自動検出で、初回アクセス時に適切な言語を表示:

const browserLang = (navigator.language || '').toLowerCase();
if (browserLang.startsWith('ja')) return 'ja';
if (browserLang.startsWith('vi')) return 'vi';
if (browserLang.startsWith('id') || browserLang.startsWith('ms')) return 'id';
if (browserLang.startsWith('zh')) return 'zh';
return 'en';

7. alert() を使わない — カスタムToast UI

alert() はブラウザをブロックしてUXが悪いので、ライブラリなしでToast通知を自作しました。

// ToastContext.jsx
const showToast = (message, type = 'success') => {
  const id = Date.now();
  setToasts(prev => [...prev, { id, message, type }]);
  setTimeout(() => removeToast(id), 3000); // 3秒で自動消去
};

8. メモリ管理 — Object URLの適切な解放

画像処理ツールではメモリリークに注意が必要です。URL.createObjectURL() で作成したURLは使用後に必ず解放します:

const url = URL.createObjectURL(file);
img.src = url;
img.onload = () => {
  // 処理実行
  URL.revokeObjectURL(url); // 即座にメモリ解放
};

AI高解像度化ではTensorFlow.jsのテンソルも明示的にdisposeする必要があります。

SPAのSEO対策

React SPAは <div id="root"></div> だけのHTMLなので、Googleクローラーがコンテンツを読めない問題があります。

対策として <noscript> タグ内に全ツールの説明文を静的HTMLで配置しました:

<noscript>
  <div>
    <h1>PDF Tools - Free Online PDF Editor</h1>
    <h2>Split PDF</h2>
    <p>Split PDF files into individual pages...</p>
    <!-- 全ツール分 -->
  </div>
</noscript>

加えて、JSON-LD 構造化データも追加しています:

<script type="application/ld+json">
{
  "@type": "WebApplication",
  "name": "PDF Tools",
  "featureList": ["Split PDF", "Merge PDF", ...],
  "inLanguage": ["en", "ja", "vi", "id", "zh"]
}
</script>

コスト

項目 費用
ホスティング(Vercel)× 2サイト 無料
ドメイン 2つ(Cloudflare) 年 $20.92(約3,200円)
SSL証明書 無料(Cloudflare)
ライブラリ すべてOSS、無料
AIモデル ブラウザ配信、無料
合計 月約267円

サーバーサイド処理がないので、ユーザーが増えてもサーバーコストが増えません。目標は Google AdSense で月267円以上の収益を出して、ドメイン代を回収することです。

まとめ

  • pdf-libpdfjs-dist の組み合わせで、ほぼすべてのPDF操作がブラウザ内で可能
  • Canvas API だけで圧縮・リサイズ・回転・切り抜き・透かし・フィルターを実現
  • カスタムバイナリエンコーダでBMP/GIF/ICO変換も対応(Canvas非対応フォーマット)
  • TensorFlow.js + ONNX Runtime でAI背景除去・高解像度化もクライアントサイドで動作
  • PNGチャンク解析でStable Diffusion等のAI生成メタデータを検出
  • コードスプリッティングで初回ロードを91%削減
  • 独自i18nで5言語対応(ライブラリ不使用で軽量)

「ファイルをアップロードしたくない」というニーズは意外と多く、プライバシーを重視する人や、低速回線環境(東南アジアなど)のユーザーに特に刺さります。

ぜひ使ってみてください!フィードバックお待ちしています。

1
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?