実装ガイド · PNG の内部

PNG キャラクターカード metadata
tEXt チャンク、JSON、インポート不具合。

クリエイター向けの実戦ガイド——PNG の tEXt チャンクをめぐる旅です。Tavern キャラクターカードが、どうやって一枚の普通の画像の中に JSON 全文を忍ばせているのか、なぜ一部のホスト・画像エディタ・CDN が黙ってそれを捨ててしまうのか、そして壊された metadata をどう検証し、保全し、再構築するか。

fol. iv.r

キャラクターカードは実のところただの PNG

Tavern のキャラクターカードを 16 進ビューアで開くと、先頭 8 バイトは 89 50 4E 47 0D 0A 1A 0A——標準的な PNG シグネチャです。ブラウザで開けばただのポートレートが表示されます。それを SillyTavern、RisuAI、Chub にドラッグすると、名前・説明・グリーティング・ロアブック・システムプロンプトを含む JSON 全体が画像と一緒に現れる。サイドカーファイルは存在しません。metadata は PNG そのものの中、すべての PNG デコーダが文句を言わずに通り過ぎるよう義務付けられた場所に入っています。

その場所が tEXt チャンクです。これが何で、何が静かに壊しているのかを理解しているかどうかが、エクスポート・アップロード・CDN を経ても生き延びるカードライブラリと、友人のマシンに届いたときには性格を一切持たない真っ白なポートレートになってしまうカードとの差を作ります。

fol. v.r

PNG は実際どう組み立てられているか

PNG は 8 バイト固定のシグネチャに続いてチャンクの連なりで構成されます。各チャンクの骨格は同じです:

┌──────────┬──────────┬──────────────┬──────────┐
│ length   │ type     │ data         │ CRC-32   │
│ 4 bytes  │ 4 bytes  │ length bytes │ 4 bytes  │
└──────────┴──────────┴──────────────┴──────────┘

ここで重要なのは 4 バイトのtypeです。PNG は各文字の大文字・小文字で扱いのルールを符号化しています:

  • 1 文字目が大文字 → クリティカルチャンク(例:IHDRIDATIEND)。デコーダは理解する義務があります。
  • 1 文字目が小文字 → 補助チャンク。デコーダはスキップして構いません。画像自体は問題なく描画されます。
  • 3 文字目は予約済みで、現在は常に大文字。
  • 4 文字目が小文字 → コピー安全——たとえエディタがそのチャンクを理解できなくても保持すべき。大文字の場合、エディタが画像をチャンクを無効化するような形で変更したならば、そのチャンクを破棄する必要があります。

tEXt は 1 文字目が小文字(補助・スキップ可)、4 文字目が小文字(コピー安全)です。仕様上は、まともなツールの間ならどこを通っても無傷で往復できるはずのチャンク——「まとも」というのが実装上の急所ですが。

fol. vi.r

tEXt、zTXt、iTXt——3 種のテキストチャンク

PNG 仕様には実は 3 種類のテキストチャンクがあります。コミュニティはキャラクターカードのために一番素朴なものを選びましたが、その理由は押さえておく価値があります:

  • tEXt ——Latin-1 のキーワード(1–79 バイト)、1 バイトの null セパレータ、そして Latin-1 のテキスト値。圧縮なし、言語タグなし。Tavern V1/V2 カードのキーワードは文字通り chara
  • zTXt ——レイアウトは同じだが、値が zlib 圧縮されている。大きなペイロードに便利な反面、書き手が圧縮を正しく実装する必要がある。
  • iTXt ——現代版:UTF-8 テキスト、任意の圧縮、任意の言語タグと翻訳キーワード。厳密に tEXt より能力が高い。

仕様的に強い iTXt があるのに、キャラクターカードがなぜ tEXt に落ち着いたのか。理由は実利的に 2 つあります。第一に、あらゆる PNG ライブラリが昔から tEXt をサポートしてきた一方、軽量・古めのツールチェーンには iTXt サポートが歯抜けな実装がまだ残っている。第二に、コミュニティは Latin-1 問題を回避するために、JSON を直接値に入れるのではなく、 UTF-8 JSON を Base64 でエンコードしてから ASCII として tEXt の値に格納する慣習を採用しました。ASCII は Latin-1 の部分集合なので、読み手がどれほど厳格に仕様を解釈してもエンコード上の問題は起こりません。

fol. vii.r

Tavern カードが実際に書き込んでいるもの

具体的には、Tavern キャラクターカードは最終の IEND チャンクの前に 1 つか 2 つの tEXt チャンクを書き込みます:

  • chara ——V1 と V2 カードで使用。値は UTF-8 JSON の Base64。V2 では JSON が spec: "chara_card_v2" というエンベロープを持ち、実体は data 配下に格納されます。
  • ccv3 ——V3 で導入された新キーワード。値は同じく UTF-8 JSON の Base64 だが、spec "chara_card_v3"。最大互換を狙うカードは両方搭載する——V2 形ペイロードの chara チャンクと、V3 形の ccv3 チャンクを並べて。

したがって最小限のデコード手順は:全チャンクを走査し、type が tEXt で、キーワードが優先順位 1 で ccv3、フォールバックで chara のものを探し、値を Base64 デコード、続けて UTF-8 デコードしてから JSON.parse するだけ。魔法も独自コンテナもなく、標準 PNG デコーダで届く範囲です。

fol. viii.r

metadata はどこで消えるのか

tEXt が補助チャンクであるため、画像を再エンコードするツールは仕様上それを捨てて構いません。実際多くのツールが捨てます。「カードが届く頃には真っ白」になる代表的なパターン:

  1. 画像エディタの「別名で保存」。 Photoshop、GIMP、Affinity、Pixelmator はそれぞれ補助チャンクの扱いに独自のポリシーがあります。デフォルトでは保持されることが多いものの、「PNG として書き出し」「Web 用に保存」、剥ぎ取り型オプティマイザを経由する経路では tEXt が黙って失われます。
  2. ブラウザによる貼り付け・ダウンロード時の再エンコード。 スクリーンショット、canvas エクスポート、バックグラウンド取得などの経路で、ブラウザがプラットフォームの PNG ライタを介して再エンコードすることがあります。canvas API は tEXt を運ぶ手段を持たないため、canvas ラウンドトリップはほぼ確実に metadata を失います。
  3. CDN の画像最適化。 Cloudflare Polish、Vercel Image Optimization、Cloudinary auto-format などは補助チャンクを冗長扱いし、積極的に再圧縮して描画に不要なものを全て剥がします。カード画像が /_next/image 経由や Polish 有効な CDN を通って配信されているなら、訪問者が落とすファイルはあなたがアップロードしたファイルではありません。
  4. WebP / AVIF 変換。 WebP と AVIF は独自の metadata コンテナ(EXIF、XMP)を持ちますが、ほとんどの変換器は PNG の tEXt をそこへブリッジしません。アップロードを WebP/AVIF に自動変換して配信するパイプラインは、明示的に止めない限り、埋め込まれたカード JSON を必ず破壊します。
  5. プラットフォームのストリッピング。 一部のソーシャルプラットフォームは EXIF GPS などのプライバシー漏洩を消すために画像をサニタイザに通します。これらのサニタイザは大体において PNG のチャンクに対しては大雑把で、EXIF だけでなく補助チャンクを全部取り除きがちです。Discord、Twitter、プライバシープロキシを経由してカードが旅をすると、向こうに着く頃には剥がされている。
  6. 補助チャンクをコピーしないリサイズライブラリ。 Sharp、ImageMagick などは tEXt 保持できますが、ほとんどのパイプラインはそれを有効にしていません。デフォルト設定の Sharp で全アップロード画像のサムネイルを生成するパイプラインは、CDN に届く前に metadata を消してしまいます。
fol. ix.r

カードを検証する方法

最速のサニティチェックは pngcheck です。PNG 検証の事実上の標準として何十年も使われてきた小さな CLI。 -t フラグでテキストチャンクが表示されます:

$ pngcheck -t card.png
OK: card.png (512x768, 32-bit RGB+alpha, non-interlaced, 87.4%).
    chunk tEXt at offset 0x00021, length 4242, keyword: chara
    chunk tEXt at offset 0x01a55, length 5180, keyword: ccv3

画像サイズだけでテキストチャンクが1 つも見えないなら、metadata は失われています。そこからプログラムでの検証なら、Python の Pillow で 5 行ほど:

from PIL import Image
import base64, json

img = Image.open("card.png")
raw = img.text.get("ccv3") or img.text.get("chara")
card = json.loads(base64.b64decode(raw))
print(card["data"]["name"])

Pillow は全ての tEXt / zTXt / iTXt チャンクを、キーワードをキーとして img.text という同じ dict に並べて公開します。 img.text が空ならそのファイルは洗濯済み。Base64 デコードで例外が出るなら、値が Base64 ではなく生の UTF-8 で書かれている——一部のサードパーティツールが起こす既知のバグです。

fol. x.r

metadata を生かしたまま保存・配信する

カードが旅路を生き延びるための、短いが意見のあるチェックリスト:

  • チャンク保持型のツールを選ぶ。 リサイズには Pillow を img.save(..., pnginfo=...) で、Sharp なら .png({ force: true }).withMetadata() と手動チャンクコピーを併用。エディタは「preserve metadata」を明示的に謳うものを優先し、ラウンドトリップ後に pngcheck -t で必ず検証する。
  • カードファイルに対しては CDN 画像最適化をオフ。 Cloudflare ならカードのパスを Polish から除外するか cf-polish: off を付与。Vercel なら /_next/image を回避するルートで配信。正式なカードファイルは「描画対象」ではなく「ダウンロード成果物」として扱うのが正解です。
  • カードを WebP/AVIF で配信しない。 Cloudinary や imgix の auto-format はオフにする。PNG が仕様の前提とする伝送フォーマットであり、それ以外は意識的な判断であるべきです。
  • PNG のとなりに JSON バックアップを置く。 PNG は人に優しい配布フォーマット、JSON は信頼できる原本です。CDN・プラットフォーム・ブラウザがファイルを洗ったとしても、JSON があれば PNG を再構築できます。JSON がなければ、剥がされたカードはもう戻りません。
  • パイプライン変更のたびに検証する。 既知正常なテスト用カードに対して CI で pngcheck -t を 1 回回すだけで、「インポートしたら中身が空」というユーザの苦情で初めて発覚するクラスの静かなリグレッションをまとめて防げます。
fol. xi.r

一冊のコデックスで、洗われたカードを終わらせる

tavernai.cards はこれら全部を代行します: chara ccv3 チャンクを読み、JSON を V2 と V3 の双方の仕様で検証し、合理的な再配信経路を生き延びる tEXt チャンクを備えた PNG を書き戻します。途中で metadata を失ったライブラリには JSON バックアップから復元するコンバータを、見た目は問題ないのにどこかでインポートが空になるカードには、チェーンのどのステップが原因かを教えるリンタを提供します。

「インポートしたら空でした」をユーザごとにデバッグするのはもう終わりにしましょう。どの CDN・どのコンバータ・どのエディタがあなたの tEXt チャンクを食べたのかを教えてくれる、一つのワークベンチへ。

他の言語で読む
English繁體中文日本語 · このページ한국어