JPEGを再圧縮せずに、Exifなどの情報だけを削除する方法を紹介します。当サイトの写真の位置情報を消すツールで使っている処理です。
「画像のメタデータを消す」というと、Canvasに描いて保存し直す方法がよく紹介されます。たしかに消えますが、JPEGを描き直すと再圧縮がかかって、画質がわずかに変わります。Exifを消したいだけなら、画像そのものには触らず、ファイルの中の「情報の区画」だけを外して繋ぎ直すほうが素直です。その作り方と、それでも描き直しが要る場面、そして最初の公開版で見逃していた「複数スキャンのJPEG」の話を書きます。

使っているもの
ライブラリはありません。ブラウザ標準のArrayBuffer・DataView・Uint8Array・Blobだけです。向きの補正にだけcreateImageBitmapとCanvasを使います。
前提:JPEGは「区画」の並び
JPEGファイルは、先頭から順に区画(セグメント)が並んだ構造です。区画は2バイトのマーカー(FF xx)で始まり、多くの区画ではそのあとに2バイトの長さ(ビッグエンディアン、長さ自身の2バイトを含む)が続きます。ただしSOI・EOI・RSTn・TEMは長さを持たない単独マーカーです。
| マーカー | 名前 | 中身 | ツールでの扱い |
|---|---|---|---|
| FF D8 | SOI | ファイルの始まり(単独マーカー) | 残す |
| FF E0 | APP0 | JFIF(解像度など) | 残す |
| FF E1 | APP1 | Exif、またはXMP | 消す |
| FF E2 | APP2 | ICCプロファイル(色) | 残す |
| FF ED | APP13 | IPTC(写真の説明など) | 消す |
| FF FE | COM | コメント | 消す |
| FF DA | SOS | スキャンの始まり。ヘッダーの後ろに画像データが続く | 残す(画像データは読み飛ばす) |
| FF D0〜D7 | RSTn | 画像データの途中の再開始マーカー(単独) | 残す |
| FF D9 | EOI | ファイルの終わり(単独マーカー) | 残す。この後ろにデータがあれば消す |
位置情報・撮影日時・機種名が入っているのはAPP1のExifです。同じAPP1でも、編集アプリの情報(XMP)が入っていることがあり、区画の先頭の文字列で見分けます。Exifなら"Exif"、XMPなら"http://ns.adobe.com/xap"で始まります。
もうひとつ大事な前提があります。JPEGはスキャン(SOSから始まる画像データ)を複数持つことができ、スキャンとスキャンの間にもAPPやCOMの区画を置けます(プログレッシブJPEGなど)。「最初のSOSから先は全部画像データ」ではありません。RSTnはこの画像データの途中に現れます。
手順1:セグメントを順に読み取り、残す・消すを決める
DataViewでマーカーを読みながら、区画ごとに開始位置・終了位置・残すかどうかを記録します。SOSに着いたら、そのヘッダーの後ろにある画像データを読み飛ばして、次のマーカーからまた区画として読みます。EOIに着いたら終わりです。
画像データの中にもFFは出てきます。FF 00は画像データ中のFFを表し、FF D0〜FF D7は長さを持たない再開始マーカー(RSTn)です。どちらも長さ付きの区画としては解析せず、読み進めます。マーカーの前にはFFを何個か埋め草として置けるので、FF FFも1バイトずつ進めます。
function scanJpeg(buf) {
var v = new DataView(buf), n = v.byteLength;
var info = { chunks: [], complete: false, broken: false, trailer: 0 };
if (n < 4 || v.getUint16(0, false) !== 0xFFD8) return null; // JPEGではない
var p = 0;
while (p + 1 < n) {
if (v.getUint8(p) !== 0xFF) { info.broken = true; break; } // マーカーの位置にFFが無い
var m = v.getUint8(p + 1);
if (m === 0xFF) { info.chunks.push({ start: p, end: p + 1, keep: true }); p += 1; continue; } // 埋め草
if (m === 0xD8) { info.chunks.push({ start: p, end: p + 2, keep: true }); p += 2; continue; } // SOI
if (m === 0xD9) { info.chunks.push({ start: p, end: p + 2, keep: true }); p += 2; info.complete = true; break; } // EOI
if (m === 0x01 || (m >= 0xD0 && m <= 0xD7)) { // TEM / RSTn(単独マーカー)
info.chunks.push({ start: p, end: p + 2, keep: true }); p += 2; continue;
}
if (p + 4 > n) { info.broken = true; break; } // 長さが読めない
var len = v.getUint16(p + 2, false);
if (len < 2 || p + 2 + len > n) { info.broken = true; break; } // 長さが不正
var seg = { marker: m, start: p, end: p + 2 + len, keep: true };
if (m === 0xE1 || m === 0xED || m === 0xFE) seg.keep = false; // APP1(Exif/XMP) / APP13(IPTC) / COM
info.chunks.push(seg);
p = seg.end;
if (m === 0xDA) { // SOS: 画像データを読み飛ばす
var s = p;
while (p < n) {
if (v.getUint8(p) === 0xFF && p + 1 < n) {
var b = v.getUint8(p + 1);
if (b === 0x00) { p += 2; continue; } // FF 00 はデータ
if (b === 0xFF) { p += 1; continue; } // 埋め草
if (b >= 0xD0 && b <= 0xD7) { p += 2; continue; } // RSTn
break; // 次のマーカー
}
p += 1;
}
info.chunks.push({ start: s, end: p, keep: true });
}
}
if (p < n) { info.trailer = n - p; info.chunks.push({ start: p, end: n, keep: false }); } // EOIの後ろ
if (!info.complete) info.broken = true;
return info;
}
JFIF(FF E0)とICC(FF E2)は残しています。JFIFは表示に、ICCは色の再現に関わるもので、位置情報とは関係ないからです。EOIの後ろに付いているデータ(一部のスマホが動画などを付けることがあります)は、画像ではないので消す側に入れています。
短い入力・不正な長さ・EOIに届かなかった場合はbrokenを立てて、あとで「構造を最後まで確認できない」として扱います。ツールの実際のコードは、これにExifの中身を読む処理が加わったものです。
手順2:残す区画だけを繋ぎ直す
消す、といっても書き換えはしません。元のバイト列から残す部分だけを切り出して並べ、Blobにするだけです。デコードもエンコードもしないので、画像データはそのままです。
function stripJpeg(buf, info) {
var u8 = new Uint8Array(buf);
var parts = [];
info.chunks.forEach(function (c) {
if (c.keep) parts.push(u8.subarray(c.start, c.end));
});
return new Blob(parts, { type: 'image/jpeg' });
}
subarrayは元のバッファを参照するビューを作るので、切り出しの段階ではコピーが起きません。ただしBlobを作るときや、あとで再検査のために読み直すときにはバイト列が扱われるので、処理全体がゼロコピーになるわけではありません。
手順3:消す前に、何が入っているかを読む
ツールでは「入っていた情報」を一覧で見せてから消しています。ExifはAPP1の中にTIFF形式で入っていて、先頭2バイトがバイト順を表します。"II"(0x4949)ならリトルエンディアン、"MM"(0x4D4D)ならビッグエンディアン。そのあとに続くIFD(項目の表)を読み、ExifのIFDとGPSのIFDへのポインタがあれば、そこも読みます。位置情報は度・分・秒の3つの分数で入っているので、10進の緯度経度に直します。
function dms(a, ref) { // [度, 分, 秒] → 10進
var d = a[0] + a[1] / 60 + a[2] / 3600;
if (ref === 'S' || ref === 'W') d = -d;
return Math.round(d * 10000) / 10000;
}
ここは項目の種類が多く、全部書くと長くなるので省きます。各項目の解析は削除前の一覧表示に使っていて、削除そのものは項目を読めなくても(区画ごと外すので)できます。
はまりどころ3つ
その1:向きの情報を消すと、写真が横に倒れる
スマホで縦に撮った写真は、画像データは横向きのまま、ExifのOrientation(向き)で「表示するとき回転せよ」と指定されていることがあります。Exifを消すとこの指示も消えるので、写真が横倒しになります。
この場合だけ、描き直しに切り替えます。createImageBitmapにimageOrientation: 'from-image'を渡すと、向きを反映した状態で読み込めるので、それをCanvasに描いて保存します。
var bmp = await createImageBitmap(file, { imageOrientation: 'from-image' });
var cv = document.createElement('canvas');
cv.width = bmp.width; cv.height = bmp.height;
cv.getContext('2d').drawImage(bmp, 0, 0);
var blob = await new Promise(function (res) { cv.toBlob(res, 'image/jpeg', 0.92); });
Canvasで向きを補正した場合は再圧縮がかかります。ツールでは、どちらの方法で処理したかを写真ごとに表示しています。
その2:PNGとWebPは、区画の構造が違う
上のマーカー方式はJPEG専用です。PNGはチャンク、WebPはRIFFという別の構造なので、同じコードでは解析できません。ツールでは、JPEG以外は描き直しで保存しています。Canvasを通すとメタデータは持ち越されません。
ただ「持ち越されないはず」で済ませず、描き直したあとの出力も読み直しています。PNGはeXIf・tEXt・iTXt・zTXtチャンクとIENDまでの到達、WebPはEXIF・XMPチャンクとRIFFの長さの整合を確かめます。
その3:消したあと、先頭から末尾までもう一度調べてから保存ボタンを出す
保存前に、できあがったBlobをもう一度scanJpegにかけて、消す対象の区画(Exif・XMP・IPTC・コメント)と末尾の付加データが残っていないこと、EOIまで構造を確認できたことを調べます。残っていれば赤く表示して、保存ボタンを出しません。
初期の公開版では、最初のSOSで読み取りをやめていました。その方式だと、複数スキャンのJPEGでスキャンの間に置かれたXMPやコメントを削除できず、しかも再検査も同じ関数なので「残っていません」と表示していました。マーカーの前に埋め草のFFがある場合も、途中で読み取りが止まっていました。公開後のレビューでこの見逃しを指摘され、2026年9月12日に上のコードのようにファイル全体を読む方式へ改めました。スキャンの後ろにXMPとコメントを置いた試験用JPEG、FFの埋め草を入れたJPEG、EOIの後ろにデータを付けたJPEG、途中で切れたJPEGで、削除と再検査の結果が一致することを確認しています。

まとめ
JPEGのExifは、ファイルの中の区画を外して繋ぎ直すだけで消せます。DataViewでマーカーを読み、APP1・APP13・COMを外して、残りをBlobに並べ直す。画像データには触らないので画質は変わりません。ただしスキャンは複数あり得るので、最初のSOSで止めずにEOIまで読むこと。向きの情報がある写真とPNG・WebPはCanvasで描き直し、消したあとは先頭から末尾までもう一度調べてから保存ボタンを出します。
この仕組みで動いているのが写真の位置情報を消すツールです。位置情報が入っているかどうかを調べるだけの使い方もできます。
参考: ITU-T T.81(JPEG規格・B.1.1.2、B.2.1、B.2.4)/MDN: DataView/MDN: createImageBitmap(imageOrientation)/File API: Blob constructor/CIPA(Exif規格 DC-008 の策定団体)


