piexifjs で日本語の EXIF を書こうとして InvalidCharacterError で落ちる話と、その直し方

ブラウザ上で撮影した画像に EXIF(撮影者名や説明)を書き込む処理を piexifjs で実装してたら、英数字のときは普通に通るのに日本語を入れた瞬間に InvalidCharacterError が出てクラッシュした。
原因を追っていったら、piexifjs 単体のバグというより btoa() の昔ながらの制約のようです。

TL;DR

  • btoa() は各文字のコードポイントが 0x00〜0xFF(Latin1範囲)でなければ InvalidCharacterError を投げる
  • piexifjs は最終的に内部で btoa(バイナリ文字列) を呼ぶので、"撮影:@taro" のようなUTF-8マルチバイト文字をそのまま渡すと死ぬ
  • 解決策は 事前に TextEncoder で UTF-8 バイト列にしてから、各バイトを String.fromCharCode で Latin1 範囲の文字列に詰め直して渡すこと
  • これで EXIF には UTF-8 バイト列がそのまま書き込まれ、最近のEXIFビューアならちゃんと日本語で表示される
  • UserComment だけは 8 バイトの文字コード指定プレフィックスが必要で、他のタグ(Make / Model / ImageDescription など)とは扱いが違うので注意

何が起きるか

やっていたのは、Canvas で書き出した JPEG に「撮影者」を埋め込むだけのシンプルな処理です。

import piexif from "piexifjs";

const exifObj = {
  "0th": {
    [piexif.ImageIFD.Model]: "撮影:@taro",
  },
};
const exifStr = piexif.dump(exifObj);              // ← ここで💥
const newDataUrl = piexif.insert(exifStr, dataUrl);

これを走らせると、piexif.dump(exifObj) の呼び出しの中で次のエラーが出ます。

InvalidCharacterError: Failed to execute 'btoa' on 'Window':
The string to be encoded contains characters outside of the Latin1 range.

Model"taro" に変えると普通に通るので、明らかに日本語が原因。けど piexifjs の README には「日本語ダメ」とは書いてないし、Issue を漁ると同じ症状の報告がポツポツある状態でした。

原因①: btoa() の Latin1 制約

btoa() は文字列を Base64 にエンコードする関数ですが、入力の各文字のコードポイントが 0x00〜0xFF(Latin1範囲)でなければエラーになります。これは「btoa() は本来バイナリ文字列(1文字=1バイトとみなされる文字列)を受け取る」という古い設計の名残です。

btoa("abc")     // OK → "YWJj"
btoa("撮影:")  // InvalidCharacterError

撮影 のような日本語は UTF-16 上でも単一コードユニットでコードポイントが 0x64AE などになり、軽く Latin1 範囲を超えます。なので btoa() は受け取った時点で拒否します。

これは MDN や Designcise の解説でも丁寧に書かれているお馴染みの罠で、現代だと「Unicode 文字列を Base64 にしたいときは TextEncoder で UTF-8 バイト列にしてから btoa する」のがセオリーです(参考: How to Fix JavaScript btoa() "outside of the Latin1 range" Error?base64.guru: Implementing Unicode support for btoa() and atob())。

原因②: piexifjs は最終的に btoa() を通る

問題は、piexifjs が EXIF を組み立てる過程の最後で btoa() を経由する設計になっていることです。

exifObj["0th"][Model] = "撮影:@taro"
        ↓ piexif.dump(exifObj)
   タグごとにバイナリ文字列を組み立てる
        ↓
   内部で btoa(バイナリ文字列) を呼ぶ ← ここで💥
        ↓
   Base64 → Data URL に挿入

piexifjs は「JPEG セグメントをバイナリ文字列で扱う」設計で、最後に Base64 にして JPEG の Data URL に注入します。値として渡した "撮影:@taro" は途中でそのままバイナリ文字列に連結されるので、UTF-16 のコードポイントがそのまま btoa() に流れて爆発するわけです。

つまり「piexifjs の値として日本語をそのまま渡す」のがそもそも想定外、と言うのが正確な理解です。

原因③: EXIF 仕様側の事情

ここでもう一段の歴史的背景があります。EXIF の文字列タグはもともと ASCII 前提 で定義されていて、Make / Model / ImageDescription などは「ASCII 文字列」と仕様上書かれていました。日本語のような非 ASCII 文字をどう書くかは仕様外で、各実装が独自に解釈する状態が長く続いています。

例外的に多バイト文字を意識して作られているのが UserComment(タグ 0x9286)です。こちらは 先頭 8 バイトに文字コード指定"ASCII\0\0\0" / "UNICODE\0" / "JIS\0\0\0\0\0" / "\0\0\0\0\0\0\0\0")を入れることになっていて、UNICODE の場合は UTF-16 BE でエンコードします(参考: UserCommentフィールドの値のエンコード、デコードをするヘルパー (hMatoba/gist))。

そして 2023 年に IPTC からアナウンスされた Exif 3.0 で、ようやくフィールドの型として UTF-8 が正式に追加されました(参考: Exif 3.0 released, featuring UTF-8 support - IPTC)。が、対応しているビューアやライブラリはまだ多くなく、piexifjs(最終更新が古い)も非対応です。

ただ実運用としては、Exif 3.0 を待たずとも ImageDescriptionModel などの ASCII フィールドに UTF-8 のバイト列をそのまま書き込んでおけば、Lightroom や Windows のプロパティ、digiKam など大半のビューアは UTF-8 として解釈してくれる、という暗黙の慣習があります(exiftool もデフォルトで UTF-8 扱い)。今回もこの慣習に乗ることにしました。

解決方法: 事前に UTF-8 バイト列 → Latin1-safe 文字列に変換して渡す

要するに、piexifjs の値として 「UTF-8 でバイト列にしたものを、見た目だけ Latin1 範囲の文字列にしたもの」 を渡せばいいわけです。流れはこう。

"撮影:@taro"
  ↓ TextEncoder.encode() で UTF-8 バイト列化
[0xE6, 0x92, 0xAE, 0xE5, 0xBD, 0xB1, 0xEF, 0xBC, 0x9A, 0x40, ...]
  ↓ 各バイトを String.fromCharCode で 1文字ずつにする
"\xE6\x92\xAE\xE5\xBD\xB1\xEF\xBC\x9A\x40..."
  ↑ 全ての文字が 0x00〜0xFF に収まる「バイナリ文字列」

こうすれば piexifjs 内部の btoa() は文句を言わず、EXIF タグの中身には UTF-8 バイト列がそのまま書き込まれます。

実装はこんな感じになります。

/**
 * Unicode 文字列を UTF-8 バイト列に変換し、
 * 各バイトを 1 文字にした「Latin1 範囲のバイナリ文字列」を返す。
 * piexifjs の値として渡すと、btoa() を通っても落ちなくなる。
 */
export function utf8ToBinaryString(s: string): string {
  const bytes = new TextEncoder().encode(s);
  let out = "";
  for (let i = 0; i < bytes.length; i++) {
    out += String.fromCharCode(bytes[i]);
  }
  return out;
}

使い方:

import piexif from "piexifjs";
import { utf8ToBinaryString } from "./utf8ToBinaryString";

const exifObj = {
  "0th": {
    [piexif.ImageIFD.Make]: utf8ToBinaryString("くぅカメラ"),
    [piexif.ImageIFD.Model]: utf8ToBinaryString("撮影:@taro"),
    [piexif.ImageIFD.ImageDescription]: utf8ToBinaryString(
      "桜が綺麗だった日のメモ📸"
    ),
  },
};
const exifStr = piexif.dump(exifObj);             // 落ちない
const newDataUrl = piexif.insert(exifStr, dataUrl);

これで piexif.dump() は通り、生成された JPEG を Windows のプロパティや exiftool で覗くと、ちゃんと日本語が読めます(exiftool は標準で UTF-8 として解釈する)。

$ exiftool out.jpg | grep -E "Make|Model|Image Description"
Make                            : くぅカメラ
Camera Model Name               : 撮影:@taro
Image Description               : 桜が綺麗だった日のメモ📸

String.fromCharCode(...bytes) で一発じゃダメなの?

短いデータなら次のように書けば 1 行で済むんですが、

const out = String.fromCharCode(...bytes);

これは引数を スプレッドで全部スタックに積む ので、bytes.length が大きいと Maximum call stack size exceeded を踏みます。EXIF の値自体は短いことが多いとはいえ、ループで書いておくのが無難です。気になる場合は 8192 バイトずつチャンクして連結する書き方でもOK。

function utf8ToBinaryString(s: string): string {
  const bytes = new TextEncoder().encode(s);
  const CHUNK = 0x8000;
  let out = "";
  for (let i = 0; i < bytes.length; i += CHUNK) {
    out += String.fromCharCode(...bytes.subarray(i, i + CHUNK));
  }
  return out;
}

UserComment だけは別扱いにする

UserComment は仕様上 先頭 8 バイトに文字コード指定を入れる必要があります。同じノリで素の UTF-8 を突っ込むと、ビューアによっては「ASCII として解釈して文字化け」「先頭 8 文字がプレフィックスとして食われる」みたいなことが起きます(実際 piexifjs の Issue #67 はこれ系のバグ報告)。

きちんと書くなら、"UNICODE\0" プレフィックス + UTF-16BE で組み立てる必要があります。

function buildUserCommentUnicode(s: string): string {
  // "UNICODE\0" (8 bytes) + UTF-16BE のバイト列
  const prefix = "UNICODE\0";
  let body = "";
  for (let i = 0; i < s.length; i++) {
    const code = s.charCodeAt(i);
    body += String.fromCharCode((code >> 8) & 0xff, code & 0xff);
  }
  return prefix + body;
}

exifObj["Exif"][piexif.ExifIFD.UserComment] = buildUserCommentUnicode(
  "桜が綺麗だった日のメモ"
);

将来 Exif 3.0 が普及してくれば "UTF-8\0\0\0" プレフィックスを使う選択肢も出てきますが、現状はビューア側の対応がまだ薄いので UNICODE (UTF-16BE) が無難です。

「コメントを入れる」だけなら、UserComment を避けて ImageDescription に書いてしまうほうがハマりどころが少なくておすすめです。

ハマりどころメモ

実装中に踏んだ細かい注意点をいくつか。

  • escape(encodeURIComponent(s)) 系の古いハック: ネットに古い記事が多いですが、escape は deprecated だし、サロゲートペア(絵文字)が混じると壊れます。素直に TextEncoder を使うのが現代の正解。
  • 逆方向(読み取り)も同じ対応が必要: piexif.load() で取り出した値も Latin1 1 文字 = 1 バイトの文字列で返ってくるので、TextDecoder で UTF-8 として復号して使います。 ts const bytes = Uint8Array.from([...str].map((c) => c.charCodeAt(0))); const decoded = new TextDecoder("utf-8").decode(bytes);
  • タグの最大バイト数: EXIF の文字列タグは仕様上長さに制限があるものがあるので、長文を入れたい場合は ImageDescription(実質可変長)に寄せるとよい。Model などは短く。
  • EXIF が必須でない場面では JPEG コメントセグメント (COM) という選択肢もある: メタデータの種類によってはこちらのほうがシンプル。ただし読み取れるツールは少ない。
  • ビューア依存: 上で「最近のビューアは UTF-8 で解釈してくれる」と書いたものの、本当に古い JPEG ビューアやスマホのデフォルトギャラリーアプリは ASCII 前提で文字化けすることがあります。クリティカルなメタデータは画像内に焼き込むほうが安全。

まとめ

「piexifjs で日本語が書けない」の正体は、結局のところ

  1. btoa() の Latin1 制約
  2. piexifjs が内部で btoa() を通す設計
  3. EXIF 仕様がもともと ASCII 前提で、UTF-8 対応(Exif 3.0)も普及途上

という 3 層の歴史的事情の積み重ねでした。
直し方は呼び出し側で UTF-8 バイト列を Latin1-safe な文字列に変換してから渡す だけと拍子抜けするほどシンプルなんですが、btoa() の仕様も EXIF 仕様も知らないと「なぜそうなるのか」が全然見えなくて、地味にハマる類の問題でした。

普段 TextEncoder / btoa の合わせ技を意識する場面は少ないですが、こういう古い設計のライブラリを叩くときに効いてくる知識やなぁと改めて思った一件でした。

参考