PDFを結合したいだけなのに
仕事の契約書を1つのPDFにまとめたい。ただそれだけなのに、検索して出てきた無料ツールに契約書をアップロードするのは、正直かなり抵抗があります。
- そのファイルがサーバー上でいつ消えるのか、書いてあっても確かめようがない
- 会社によっては、外部サービスへのアップロードがそもそも規約で禁止されている
- フリマに出す写真に位置情報(EXIF)が残っているのを消したいのに、消すために写真をアップロードするという矛盾
「変換のためにファイルを預ける」という前提を、そもそも無くせないだろうか。
ブラウザの上でWebAssemblyもCanvasも動く今なら、変換処理を全部ブラウザの中でやってしまえば、ファイルを送る必要がないはずです。そう考えて作ったのが「なんでも工房」です。
動画・音声の変換、PDFの結合分割・書き込み、画像の編集、QRコード生成など19個のツールが入っていますが、サーバー側の処理コードは1行も存在しません。GitHub Pagesのような静的配信でも動きます。
例えば画像圧縮では、スライダーを動かすたびに実際の圧縮をかけ直して、圧縮後の見た目と実ファイルサイズをその場で表示しています。サーバー往復が無いので、こういう「結果を見ながら決める」UIが軽く作れるのは、この構成の副産物でした。
この記事では、実際にどう作ったかと、作る過程で踏み抜いた落とし穴を書きます。
全体像:何をどのライブラリで処理しているか
| 機能 | 使っているもの |
|---|---|
| 動画・音声の変換 | ffmpeg.wasm |
| PDFの読み込み・描画 | pdf.js (pdfjs-dist) |
| PDFの生成・書き出し | pdf-lib / jsPDF |
| 画像編集全般 | Canvas API(ライブラリなし) |
| GIFアニメ生成 | gifenc |
| EXIF解析 | exifr |
| QRコード生成・読み取り | qrcode / jsQR |
| ホスティング | Vercel(静的配信のみ) |
ビルドはVite + 素のTypeScriptです。フレームワークは使っていません。
ポイントは、重いライブラリを全部 dynamic import にしていることです。
{
id: "pdf-merge-split",
label: "PDF結合・分割",
load: () => import("./tools/pdfMergeSplit").then((m) => m.buildPdfMergeSplitTool),
}
pdf.jsもffmpeg.wasmも巨大なので、トップページを開いただけで全部読み込むと目も当てられません。ツールを選んだ瞬間に初めて取りに行く形にしています。
ffmpeg.wasm をブラウザで動かす
一番「本当に動くのか?」と思われるのが動画変換だと思います。結論から言うと、普通に動きます。
読み込み
import { FFmpeg } from "@ffmpeg/ffmpeg";
import { toBlobURL } from "@ffmpeg/util";
const CORE_BASE_URL = "https://unpkg.com/@ffmpeg/core@0.12.6/dist/esm";
const ffmpeg = new FFmpeg();
await ffmpeg.load({
coreURL: await toBlobURL(`${CORE_BASE_URL}/ffmpeg-core.js`, "text/javascript"),
wasmURL: await toBlobURL(`${CORE_BASE_URL}/ffmpeg-core.wasm`, "application/wasm"),
});
ここで大事なのが toBlobURL です。CDN上のURLをそのまま coreURL に渡すと、内部でWorkerを生成する際にクロスオリジンの制約に引っかかります。一度fetchしてBlob URLにしてから渡すことで回避しています。
もう1つ、マルチスレッド版(core-mt)ではなく通常版を使っている点も地味に重要です。マルチスレッド版は SharedArrayBuffer を使うため、Cross-Origin-Opener-Policy と Cross-Origin-Embedder-Policy のヘッダ設定が必須になります。静的ホスティングでヘッダをいじりたくなかったので、シングルスレッド版を選びました。速度と引き換えに、配信環境を選ばない構成になっています。
インスタンスは使い回す
初回ロードが重いので、シングルトンにします。このとき、ロード中のPromiseも保持するのがポイントです。
let instance: FFmpeg | null = null;
let loadingPromise: Promise<FFmpeg> | null = null;
export function getFFmpeg(): Promise<FFmpeg> {
if (instance) return Promise.resolve(instance);
if (loadingPromise) return loadingPromise; // ← これが無いと二重ロードする
loadingPromise = (async () => {
const ffmpeg = new FFmpeg();
await ffmpeg.load({ /* ... */ });
instance = ffmpeg;
return ffmpeg;
})();
return loadingPromise;
}
instance のチェックだけだと、ロード完了前にもう一度呼ばれた場合に2回ロードが走ります。
実行と進捗
await ffmpeg.writeFile(inputName, await fetchFile(file));
ffmpeg.on("progress", ({ progress }) => {
onProgress(Math.min(1, Math.max(0, progress)));
});
const exitCode = await ffmpeg.exec(["-i", inputName, ...args, outputName]);
if (exitCode !== 0) throw new Error(`変換に失敗しました(終了コード: ${exitCode})`);
const data = await ffmpeg.readFile(outputName);
引数はコマンドラインのffmpegとほぼ同じです。動画圧縮なら:
["-c:v", "libx264", "-preset", "veryfast", "-crf", String(crf), "-c:a", "aac", "-b:a", "128k"]
進捗が取れるのは体験上かなり重要でした。数十秒〜数分かかる処理で、何も出ないと固まったと思われます。
⚠️ 落とし穴:仮想FSにファイルが残り続ける
ffmpeg.wasm は内部に仮想ファイルシステムを持っています。writeFile した入力も exec が吐いた出力も、明示的に消すまで残ります。変換を繰り返すとメモリを食い潰すので、finally で必ず消します。
try {
// ... exec して readFile
} finally {
await ffmpeg.deleteFile(inputName).catch(() => {});
await ffmpeg.deleteFile(outputName).catch(() => {});
}
画像編集は Canvas API だけでほぼ足りる
画像系のツールは外部ライブラリを一切使っていません。getImageData と globalCompositeOperation を知っていれば、だいたい何とかなります。
背景透過:色の距離でアルファを0にする
クリックした色に近い画素を透明にする、いわゆる「同色透明化」です。
const maxDist = Math.sqrt(3 * 255 * 255); // RGB空間の最大距離
const threshold = (tolerance / 100) * maxDist; // 許容範囲スライダーの値
const d = imageData.data;
for (let i = 0; i < d.length; i += 4) {
const dr = d[i] - picked.r;
const dg = d[i + 1] - picked.g;
const db = d[i + 2] - picked.b;
if (Math.sqrt(dr * dr + dg * dg + db * db) <= threshold) {
d[i + 3] = 0; // アルファだけ0にする
}
}
やっていることは単純なRGBのユークリッド距離です。厳密にやるならLab色空間で比較すべきですが、実用上はこれで十分でした。
モザイク:縮めて、拡大する
モザイクの実装は驚くほど簡単です。一度小さく縮小して、補間を切って引き伸ばすだけ。
function applyMosaicRect(ctx, x, y, w, h, blockSize) {
const small = document.createElement("canvas");
const sw = Math.max(1, Math.round(w / blockSize));
const sh = Math.max(1, Math.round(h / blockSize));
small.width = sw;
small.height = sh;
// 縮小して情報を落とす
small.getContext("2d").drawImage(ctx.canvas, x, y, w, h, 0, 0, sw, sh);
// 補間なしで引き伸ばす → ブロック状になる
ctx.imageSmoothingEnabled = false;
ctx.drawImage(small, 0, 0, sw, sh, x, y, w, h);
ctx.imageSmoothingEnabled = true;
}
imageSmoothingEnabled = false を戻し忘れると、以降の描画が全部ガビガビになるので注意。
ちなみに「ぼかし」のほうはもっと簡単で、ctx.filter = "blur(10px)" が使えます。
ペンでなぞった所だけモザイクをかける
ここは少し工夫が要りました。ペンでなぞった軌跡にだけモザイクをかけたいのですが、ストロークごとに部分モザイクを計算すると、ブロックの境界がズレて汚くなります。
解決策は、先に「全面モザイク版」のcanvasを作っておき、それをパターンとしてストロークで塗るという方法です。
// mosaicFull は画像全体にモザイクをかけた canvas(スライダー変更時に作り直す)
const pattern = penCtx.createPattern(mosaicFull, "no-repeat");
penCtx.strokeStyle = pattern; // ← 色ではなく画像で線を引く
penCtx.lineWidth = penSize;
penCtx.lineCap = "round";
penCtx.beginPath();
penCtx.moveTo(from.x, from.y);
penCtx.lineTo(to.x, to.y);
penCtx.stroke();
createPattern を no-repeat で作ると原点合わせでそのまま重なるので、なぞった部分だけモザイク版が露出する形になります。ブロックの位置は全面版と完全に一致するため、何度なぞっても継ぎ目が出ません。
消しゴム
ctx.globalCompositeOperation = "destination-out";
ctx.stroke(); // 描いた部分が透明になる
これだけです。
元に戻す
操作の直前に ImageData を積むだけ。素朴ですが十分実用的でした。
history.push(ctx.getImageData(0, 0, canvas.width, canvas.height));
if (history.length > 20) history.shift(); // 上限を決めないとメモリを食う
ハマった落とし穴
ここからが本題かもしれません。実装そのものより、こっちで時間を溶かしました。
1. <input type="range"> に代入した値は step に丸められる
画質スライダーで「0.35」のような細かい値を扱いたかったのですが、どうしても保持できませんでした。
原因は仕様です。range要素に .value を代入すると、HTMLの値サニタイズ処理で step の倍数に丸められます。step が 0.1 なら 0.35 は 0.4 になります。
input.step = "0.1";
input.value = "0.35";
console.log(input.value); // "0.4"
対策は、真の値を別に持ち、range要素は表示専用として扱うこと。
let currentValue = 0.35; // こっちが真の値
input.value = String(currentValue); // range側は丸められてもよい
return { getValue: () => currentValue }; // range.value は読まない
2. CSSはスライダーの現在値を知れない
「ここまで色が満ちるメーター」をCSSだけで作ろうとして気付きました。CSSからは現在値が見えないので、JS側からCSS変数で渡すしかありません。
const pct = ((value - min) / (max - min)) * 100;
input.style.setProperty("--fill", `${pct}%`);
.slider-input::-webkit-slider-runnable-track {
background-color: rgba(124, 108, 240, 0.18);
background-image: linear-gradient(90deg, #7c6cf0, #ff8fb8);
background-repeat: no-repeat;
background-size: var(--fill) 100%; /* ← ここ */
}
注意点が2つ。値が変わる経路すべてで更新すること(ドラッグ・数値入力・±ボタン…どれか1つ忘れると、そこだけ塗りが追従しません)。そして min/max で正規化すること。min=2, max=60 のスライダーで値が8のとき、塗りは 8% ではなく (8-2)/(60-2) = 10.3% です。
3. 1つの要素に ::after は1つしか作れない
これが一番ひどい事故でした。
ツールチップを [data-tooltip]::after で実装していたところに、ボタンの光沢アニメーションを .convert-button::after で追加しました。同じ要素の同じ擬似要素を2つのルールが奪い合う形になり、詳細度と記述順で1プロパティずつ勝敗が決まる、という地獄が発生します。
/* ツールチップ */
[data-tooltip]::after { content: attr(data-tooltip); left: 50%; }
/* 光沢(これを後から足した) */
.convert-button::after { content: ""; left: -60%; width: 40%; }
.convert-button:hover::after { left: 130%; } /* ← 詳細度が高いので勝つ */
結果、一部のボタンでツールチップが完全に消え、別のボタンではホバー時にツールチップが画面の右方向にすっ飛んでいきました。
対策はシンプルで、::after はツールチップ専用と決め、装飾は ::before を使う。要素あたり ::before と ::after は1つずつしか無い、という当たり前の事実を意識して用途を割り当てるべきでした。
4. overflow: hidden は「自分の外に出る子」も切り取る
上の光沢アニメーションを要素内に収めるため、ボタンに overflow: hidden を付けました。すると今度はツールチップが表示されなくなりました。
ツールチップはボタンの 上 に出ます。ボタンは position: relative なので、絶対配置されたツールチップの基準(包含ブロック)になります。つまり overflow: hidden の対象は「ボタンの内側」ではなく「ボタンを基準に配置された全て」なので、外にはみ出すツールチップごと切られます。
最終的に、ツールチップを持たないボタンにだけ光沢を適用する形にしました。
.convert-button:not([data-tooltip]) { overflow: hidden; }
.convert-button:not([data-tooltip])::before { /* 光沢 */ }
このとき、「透明度が1になっているから表示されているはず」と確認したのが失敗でした。opacity は 1 でも、クリップされていれば見えません。スクリーンショットを撮ってピクセルを数えるまで、直ったと勘違いしていました。視覚的な効果は、計算値ではなく実際の描画で確認すべきです。
5. overflow-y だけ指定しても、両方の軸がクリップされる
縦だけスクロールさせたくて overflow-y: auto を指定したら、横方向にはみ出す要素まで切られました。
これも仕様で、片方の軸を visible 以外にすると、もう片方が visible のままでは居られず auto に計算されるというルールがあります。片軸だけクリップ、はできません。
6. opacity: 0 の要素もレイアウト領域を占める
ある画面幅のときだけ、意味不明な横スクロールバーが出る現象に悩まされました。全要素の getBoundingClientRect() を調べても、はみ出している要素が1つも見つからない。
犯人は非表示状態のツールチップでした。opacity: 0 は「見えない」だけで、レイアウト上の場所は占め続けます。そして擬似要素なので querySelectorAll では引っかからず、原因特定に時間がかかりました。
display: none にして、ホバー時だけ display: block にすることで解決しています(フェードは諦めました)。
なお、overflow-x: clip で蓋をする方法も試しましたが、Chromeは body や html に指定した clip をビューポートのスクロールに反映してくれませんでした。小手先で隠すより、レイアウトから消すのが確実です。
7. クリックで出した入力欄からフォーカスが奪われる
画像上をクリックしてテキスト入力欄を出す機能で、入力欄が一瞬出てすぐ消えるという現象が起きました。
canvas.addEventListener("pointerdown", (e) => {
showTextInput();
textInput.focus(); // ← focusしているのに奪われる
});
原因は、pointerdown ハンドラの後に走るブラウザ標準の「クリックした要素にフォーカスを移す」処理です。こちらの focus() の後に実行されるので、フォーカスがcanvasに戻ってしまいます。
canvas.addEventListener("pointerdown", (e) => {
e.preventDefault(); // ← デフォルトのフォーカス移動を止める
showTextInput();
textInput.focus();
});
Googleに1ページしか登録されていなかった
最後に、技術的には初歩的ですが、個人開発では致命的だった話を。
19個のツールを作り、サイトマップも入れ、Search Consoleにも登録しました。しばらくして「ページ」を見たら、インデックス登録済みページ数: 1。
原因はルーティングでした。
https://example.com/#/app/pdf-merge-split
Googleは # 以降を無視します。ハッシュはあくまで「同一ページ内の位置」を指す仕様なので、19個のツールが全部「同じ1ページ」として扱われていたわけです。
これが何を意味するかというと、「PDF 結合 無料」で検索した人に対して、Googleは「PDF結合のページ」を出したいのに、そんなページは存在せず、全機能が雑多に載ったトップページ1枚しか無い。当然、どの検索語でも中途半端な評価になります。
やったこと
1. 実パスに変更
変更前: /#/app/pdf-merge-split
変更後: /tools/pdf-merge-split
location.hash を捨てて history.pushState に。古いURLは replaceState で新URLに転送し、ブックマークを死なせないようにしました。
2. ビルド時に各ページのHTMLを実ファイルとして生成
Viteのプラグインで、dist/index.html をテンプレートにして、ツールごとに title / description / canonical を差し替えたHTMLを吐き出します。
function seoPrerender(): Plugin {
return {
name: "seo-prerender",
apply: "build",
closeBundle() {
const template = readFileSync("dist/index.html", "utf8");
for (const route of TOOL_SEO) {
const html = buildRouteHtml(template, route); // titleなどを差し替え
writeFileSync(`dist/${route.path}.html`, html, "utf8");
}
// sitemap.xml も同じリストから生成する
},
};
}
ルート情報を1つの配列で持ち、そこからページ生成もサイトマップ生成も行うようにしています。こうしておくと「サイトマップには載っているのにページが無い」という食い違いが原理的に起きません。
3. <button> を <a href> に変える
これを見落としていました。トップページの機能カードが <button> + クリックハンドラだったので、Googleがたどれるリンクが1本も無い状態でした。サイトマップだけでも一応発見はされますが、リンクで繋がっているほうが評価は上がります。
const card = document.createElement("a");
card.href = toolPath(f.id);
card.addEventListener("click", (e) => {
// 修飾キー付きのクリックはブラウザに任せる(新しいタブで開けるように)
if (e.metaKey || e.ctrlKey || e.shiftKey || e.button !== 0) return;
e.preventDefault();
navigate(toolPath(f.id));
});
結果、インデックス対象は 1 → 20 になりました。
⚠️ 落とし穴:rewrites が効かないことがある
SPAでパスルーティングをする場合、未知のURLを index.html に転送する設定が定番です。
{ "rewrites": [{ "source": "/(.*)", "destination": "/index.html" }] }
ところが、この設定がまったく効きませんでした。同じ vercel.json の cleanUrls は効いているのに、rewrites だけ無視され、未知のパスは全て NOT_FOUND を返す。記法を公式ドキュメントどおりの /:path* に変えても同じでした。
原因究明に時間をかけるより、そもそも rewrites に依存しない設計にする方が早いと判断しました。アプリが遷移しうるURLを全て実ファイルとして出力してしまえば、フォールバックは要りません。カテゴリページもASCIIのスラッグを振って実HTMLを生成し、noindex, follow を付けて出力しています。
副次的な効果として、存在しないURLがちゃんと404を返すようになりました。SPAフォールバックだと何を入れても200で中身が空、という「ソフト404」になりがちなので、SEO的にはむしろこちらのほうが健全です。
まとめ:ブラウザだけでどこまでできるか
できること
- 一般的なサイズの動画・PDF・画像なら、実用的な速度で処理できる
- 画像編集はライブラリ無しでほぼ何でもできる
- サーバー処理が無いので、ホスティング費用は実質ゼロ
苦しいこと
- 巨大な動画はメモリと時間を食う。処理時間は端末性能に完全に依存する
- ffmpeg.wasm の初回ロードが重い(数十MB)
- 当然ながら、処理中はそのタブを閉じられない
それでも最大の利点は、ファイルが手元から出ないことです。
「アップロードしないから安全です」と書く必要すらなく、実際に通信が発生していないので、DevToolsのNetworkタブを見てもらえば一目で分かります。信頼を説明ではなく構造で担保できるのは、この作り方の一番の強みだと思っています。
同じような「アップロードしたくない」を感じている人の参考になれば幸いです。
この記事はZennにも投稿しています。
https://zenn.dev/tamatamatama/articles/b08993f901c556


