スクショPDF化
スマホのスクショを、1ページ4枚(2×2)のA4のPDFにまとめて、そのままメールやチャットで送れるツールです。サーバーはなく、静的ファイルを Cloudflare Pages に置いているだけです。スクショもPDFも、スマホの外に出ません。 処理はすべてブラウザの中(JavaScript)で行います。
このページは、その仕組みと、作るうえで判断したことの解説です。
全体の構成
ビルド工程のない素のHTML / CSS / JavaScript です。フレームワークもバンドラーも使っていません。Cloudflare Pages は、ページとJSを配るだけで、画像は受け取りません。
public/
index.html 画面(スタイルも同じファイルに書いている)
app.js 選択・プレビュー・PDF作成・共有のすべて
pdf-lib.min.js PDFを組み立てるライブラリ(vendor/ からコピー)
screenshot-pdf-offline.html オフライン版(自動生成)
vendor/pdf-lib.min.js ライブラリの原本
scripts/build-offline.js オフライン版の生成
PDFの組み立てには pdf-lib だけを使っています。読み込むスクショはブラウザ標準の機能(File・createImageBitmap・canvas)で扱うので、PDFを読んで描くライブラリは不要です。
外部通信をなくす
pdf-lib は、当初はCDN(cdnjs)から読み込んでいましたが、リポジトリに同梱して自分のドメインから配る形に変えました。「画像は送信されない」と謳うツールが、ページを開くたびに外部のサーバーへアクセスし、そこから届いたコードで画像を処理しているのは筋が悪いためです。CDNが改ざんされれば、画像を盗むコードにすり替えられることもありえます。
書体もアイコンも外部に頼りません。アイコンは Bootstrap Icons(MIT)から必要な数個ぶんだけを、SVGのままソースに埋め込んでいます。
状態は配列2本だけ
選んだスクショは、2本の配列で持ちます。
let files = []; // 選択した File(並び順がそのままPDFの順)
let objectUrls = []; // files と同じ順の、プレビュー用 objectURL
2本は常に同じ長さ・同じ順序です。追加は末尾に足すだけ、削除は両方から splice、並べ替えは両方で同じ2要素を入れ替えます。操作のたびに、プレビュー全体をまるごと描き直します(renderPreview)。1ページ4枚、数十枚までなので、差分更新よりも「画面と状態が食い違わない」ことを優先しています。
objectURL は、画像を表示するためにブラウザが一時的に発行する、メモリ上のファイルへの参照です。使い終わったら revokeObjectURL で返さないとメモリが残るため、削除・全消去のたびに必ず返しています。
選び直しに強くする
- 選ぶたびに追加します。 スマホの写真ピッカーは、前回の選択状態を覚えていません。「もう1枚足したい」ときに全部選び直さずに済むよう、置き換えではなく追加にしています。やり直したいときは「全部やり直す」を使います。
- 並べ替えは各コマの◀▶で入れ替えます。 ピッカーが返す順序は、タップした順と一致するとは限りません(機種やピッカー次第)。ドラッグ操作はスマホで誤爆しやすいため、1つ前・1つ後ろへの入れ替えだけにしています。
- 上限は40枚です。 PDFは全ページをメモリに載せたまま組み立てます。原寸の画像を何十枚も読み込むと、スマホのブラウザのタブが落ちるためです。超えた分は追加せず、画面に理由を出します。
- 作成中は操作を無視します。
buildingフラグを立てている間は、追加・削除・並べ替えをすべて受け付けません。生成ループがfilesを読んでいる最中に配列を書き換えると、枚数がずれて壊れるためです。
A4への割り付け
A4(595.28 × 841.89 pt)に、余白 24pt、コマの間のすき間 16pt で、2×2 に割り付けます。1コマの大きさは、約 266 × 389 pt です。pt はPDFの長さの単位で、1pt = 1/72 インチです。
const CELL_W = (A4_W - 2 * MARGIN - GUTTER) / 2; // 265.64
const CELL_H = (A4_H - 2 * MARGIN - GUTTER) / 2; // 388.95
並び順は「N型」(左上 → 左下 → 右上 → 右下)で固定です。縦長のスクショを、左の列に上から順に並べ、次に右の列へ進みます。コマの番号 c(0〜3)から、位置は次の2行で決まります。
function cellPos(c) {
return { col: Math.floor(c / 2), row: c % 2 };
}
画像は「切らずに」収める
スクショの縦横比はバラバラです。コマに合わせて引き伸ばすと歪み、はみ出し分を切ると文字が欠けます。そこで、縦横比を保ったままコマに収まる最大の大きさにして、中央に置きます(containRect)。コマと縦横比が違う画像には、余白ができます。
const scale = Math.min(cellW / imgW, cellH / imgH);
座標の向きが逆
PDFの座標は、ページの左下が原点で、上に行くほど値が大きくなります。画面(上から下)の感覚と逆です。「上から row 行目」のコマの下端を求めるときは、ページの高さから引いていきます。
const cellYBottom = A4_H - MARGIN - CELL_H - row * (CELL_H + GUTTER);
番号をPDFにも焼く
プレビューでは、各コマの左上に番号の丸バッジが出ます。PDFにも同じ番号を描きます。印刷したあとや、保存して見返すときに、何枚目のスクショかが分かるようにするためです。バッジの位置は、画像ではなくコマの左上を基準にしています。画像の収まり方(余白)が違っても、番号は同じ場所にそろいます。
画像の埋め込み
pdf-lib が直接埋め込めるのは、PNGとJPEGだけです。それ以外(WebP など)は、一度 canvas に描いてPNGに変換してから埋め込みます。
| 画像 | 埋め込み方 | 理由 |
|---|---|---|
| PNG | そのまま | 作り直さないので、無劣化 |
| JPEG(回転なし) | そのまま | 同上 |
| JPEG(EXIFの回転つき) | canvas で回転 → JPEG(品質0.95) | 向きをプレビューと合わせるため(次項) |
| WebP・その他 | canvas でPNGに変換 | pdf-lib が扱えない形式のため |
PNGとJPEGは、ファイルの中身をバイト列のままPDFに入れます。再エンコードしないので、スクショの文字がにじみません。 同じ理由で、既定では画質を落とす処理を一切しません。
写真の向き(EXIF)
スマホで撮った写真のJPEGには、「この写真は90度回して表示する」という向きの情報(EXIFの Orientation)が入っていることがあります。画素そのものは横向きのまま、向きだけが別に書かれています。
- 画面の
<img>は、この向きを見て回転して表示します - pdf-lib の
embedJpgは、この向きを無視します
放っておくと、プレビューでは縦なのに、PDFでは横向きに入る、という食い違いが起きます。そこで、JPEGの先頭を読んで Orientation を取り出し、回転が必要なものだけを canvas 経由に切り替えています。
if (jpegOrientation(buf) <= 1) return await doc.embedJpg(buf); // 回転なし:そのまま
return await doc.embedJpg(await toCompressedJpegBytes(file, Infinity, 0.95));
読み取りは、JPEGのマーカー(FF E1 の "Exif" の部分)をたどって、TIFF形式の表から 0x0112 のタグを探すだけの、30行ほどの関数です。ライブラリは使っていません。canvas に描くときは createImageBitmap(file, { imageOrientation: "from-image" }) で、回転を適用した状態の画素を取り出します。スクショ(PNG)には、通常この情報がありません。
「軽量化する」は、小さくなったときだけ採用する
メールやLINEで送れる大きさに収めたいときのために、「軽量化する」のチェックを用意しています。オンにすると、画像を次のように作り直します。
- 長辺を 1600px までに縮める(それより小さい画像は縮めない)
- JPEG(品質0.82)にする
- 元のファイルより小さくなった場合だけ、作り直したほうを埋め込む
1コマは、A4の4分の1の大きさ(約 9.4 × 13.7 cm)にしか載りません。長辺 1600px あれば、印刷でも粗さは出ません。
手順3があるのは、実測で「軽量化したら、かえって大きくなった」ことが確認できたためです。スクショは、同じ色が広く続く画像で、PNGの無劣化圧縮がもともと効率的です。縮小すると境目がぼやけて色数が増え、JPEGにすると元より大きくなる場合があります。「軽量化」と名乗る以上、悪化する画像は元のまま入れることにしました。写真のような高解像度・多色の画像を混ぜたときに、この処理が効きます。
軽量化を何枚に適用したかは、完了メッセージに出ます(「3枚を軽量化」「対象なし・元のまま」)。
共有と保存
PDFはメモリ上の Blob として持ち、「PDF共有」を押したときにはじめて File にして渡します。
if (shareApiPresent && navigator.canShare({ files: [file] })) {
await navigator.share({ files: [file], title: filename });
}
navigator.share は、OS標準の共有シート(メール・LINE・Xなど)を呼び出す仕組みです。スマホでは、「ファイルを保存してから添付する」手間なく、その場で送り先を選べます。これが、このツールを作った一番の目的です。
- 対応は限られます。 Android の Chrome や iOS の Safari では動きますが、PCの Firefox などでは使えません。HTTPS(または localhost)でないと、APIそのものが現れません。
- ページを開いたときに、ボタンの表示を決めます。 非対応なら、最初から「PDF保存」に切り替えます。押してから「できません」と言うよりも、押す前に正しい名前を出すほうが親切だからです。
- 押したときにも、もう一度確かめます。 APIがあっても、ファイルの種類によっては共有できないことがあるため、
canShareで最終判定をします。 - 共有に失敗したら、保存に切り替えます。 共有シートを閉じただけ(
AbortError)なら何もしません。それ以外の失敗(対応アプリがない等)では、ダウンロードに切り替えて、画面にそう表示します。
保存は、<a download> を一時的に作ってクリックし、objectURL を5秒後に返す、定番の方法です。ファイル名は screenshots-年月日-時分.pdf です。
アプリ内ブラウザでは動かないことがある
XなどのSNSアプリに内蔵されたブラウザは、ファイル選択・共有・保存を、アプリ側が塞いでいることがあります。ページ側の JavaScript からは解除できません。
「外部のブラウザへ自動で切り替える」方法も試しましたが、実機で反応しませんでした。ブラウザの種類を User-Agent から見分ける方法も、実機で2回とも外れました。そこで、判定をやめて、開いたときに一度だけダイアログで案内する形にしています(「…」メニューから「ブラウザで開く」を選んでください、という内容です)。閉じたら localStorage に記録して、次からは出しません。オフライン版(file://)では、無関係な案内なので出しません。
オフライン版
public/screenshot-pdf-offline.html は、HTML・JavaScript・pdf-lib をすべて1枚のファイルに埋め込んだ版です。ダウンロードして保存すれば、ネットにつながらなくても動きます。ソースはテキストエディタでそのまま読めるので、画像をどう処理しているかを、自分の目で確かめられます。
この1枚は、手で保守していません。scripts/build-offline.js が、公開版と同じソース(index.html・app.js・vendor/pdf-lib.min.js)から作ります。Node の標準機能だけで動き、npm run dev と npm run deploy の前に自動で走ります。やっていることは文字列の置換です。
<script src="…">を、ファイルの中身を入れた<script>に置き換える- アイコン(favicon)を
data:URIにして埋め込む - 「外部に送信されません」の説明文を、オフライン版向けの文に差し替える
置換は、対象が見つからなければ、その場でエラーにして止めます。 JavaScript の String.replace は、対象がなくても黙って通過します。実際、公開版の文言を直したときに、オフライン版の差し替え文が当たらなくなっていたことが、この確認を入れて初めて分かりました。
file:// で開くと、共有機能(Web Share API)はブラウザの仕様で使えません。オフライン版の保存ボタンは、常に「PDF保存」になります。
限界と使うときの注意
- 40枚までです。それ以上は、2回に分けて作ってください
- 出力は、1ページに4枚の画像だけのPDFです。スクショの文字を選択・検索することはできません
- 並び順は、N型の固定です。Z型(左上 → 右上 → 左下 → 右下)には切り替えられません
- HEICなど、ブラウザがデコードできない形式は、作成に失敗することがあります。失敗したときは、「画像の形式を確認するか、枚数を減らしてもう一度お試しください」と表示します
- 画像を縮める「軽量化」は、元より小さくなる場合だけ働きます。スクショだけなら、対象がないことが多いです
- 個人情報の入った画像は、PDFにしても、そのままです。墨消しが必要なら、PDFツールの墨消しを使ってください
デザインシステム
掲載予定です。