實作指南 · PNG 結構內幕

PNG 角色卡 metadata
tEXt 區塊、JSON 與匯入失敗。

一份給創作者的實戰指南——關於 PNG 的 tEXt 區塊:Tavern 角色卡格式如何把整份 JSON 藏進一張普通的影像裡、為什麼某些平臺、影像編輯器與 CDN 會默默地把它丟掉、以及該怎麼驗證、保留並重建被破壞的 metadata。

fol. iv.r

角色卡其實就是一張 PNG

用十六進位檢視器打開一張 Tavern 角色卡,最前面八個 byte 是 89 50 4E 47 0D 0A 1A 0A——標準的 PNG 簽名。用瀏覽器打開,看到的是一張肖像。把同一個檔案拖進 SillyTavern、RisuAI 或 Chub,整份 JSON——名字、敘述、開場白、世界書、system prompt——就和圖一起出現了。沒有附加檔,metadata 就藏在這張 PNG 本身裡,藏在任何 PNG 解碼器都必須無條件略過的位置。

那個位置就是 tEXt 區塊。看懂它是什麼、又是什麼東西在悄悄破壞它,就是「角色卡庫經得起每一次匯出、上傳與 CDN 重寫」與「同一張卡到了朋友的機器上只剩一張面無表情的肖像」之間的差別。

fol. v.r

PNG 檔案到底是怎麼組成的

PNG 由固定 8 byte 的簽名加上一串區塊(chunk)組成,每個區塊的結構都一樣:

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

重點在於那四個 byte 的type。PNG 用每個字母的大小寫來描述處理規則:

  • 首字母大寫 → 關鍵區塊(如 IHDRIDATIEND),解碼器必須讀懂。
  • 首字母小寫 → 輔助區塊,解碼器可以跳過,影像仍然能正常繪出。
  • 第三個字母保留,目前一律大寫。
  • 末字母小寫 → 安全可複製——即使編輯器不認識這個區塊也應該照樣保留;大寫則表示一旦編輯器對影像做了會讓區塊失效的修改,就必須丟棄它。

tEXt 是首字母小寫(輔助、可跳過)、末字母小寫(安全可複製)的區塊。理論上,它應該能在任何規矩的工具之間完整往返。實務上,「規矩」這兩個字才是承重牆。

fol. vi.r

tEXt、zTXt、iTXt——三種文字區塊

PNG 規範裡其實定義了三種文字區塊。社群最後選了最樸素的那個來承載角色卡,但理由值得一提:

  • tEXt ——Latin-1 編碼的關鍵字(1–79 byte),一個 null 分隔符,再加一段 Latin-1 文字值。不壓縮、不帶語言標籤。Tavern V1/V2 角色卡用的關鍵字就是 chara
  • zTXt ——格式相同,但文字值經過 zlib 壓縮,適合放大量內容,代價是每個寫入端都得正確實作壓縮。
  • iTXt ——現代版:UTF-8 文字、可選壓縮、可選語言標籤與翻譯後的關鍵字,能力嚴格優於 tEXt

既然 iTXt 在規範上比較強,為什麼角色卡社群還是選了 tEXt?兩個務實的原因。第一,每個 PNG 函式庫都老早就支援 tEXt,但有些較輕量或較舊的工具鏈對 iTXt 的支援仍不齊全。第二,社群用一個取巧的方式繞過了 Latin-1 的限制:不直接把 JSON 寫進去,而是先 把 UTF-8 JSON 用 Base64 編碼,再把這串純 ASCII 放進 tEXt 的值。ASCII 是 Latin-1 的子集,不論讀取端怎麼嚴格解釋規範都不會出問題。

fol. vii.r

Tavern 角色卡實際寫了什麼

具體來說,一張 Tavern 角色卡會在最後的 IEND 區塊之前寫入一到兩個 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 區塊。

最小化的解碼流程因此是:依序走過所有 chunk,找到 type 為 tEXt、關鍵字優先取 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 來回一次幾乎一定會弄丟 tEXt,因為 canvas API 根本沒有把它帶過去的途徑。
  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 chunk 一視同仁,把所有輔助區塊一併移除。角色卡在 Discord、Twitter 或某個隱私代理之間繞一圈,到了另一端就被剝光了。
  6. 縮圖函式庫沒有複製輔助區塊。 Sharp、ImageMagick 等工具都能保留 tEXt,但多數管線並不會明確啟用。一個用預設參數呼叫 Sharp 來產生縮圖的流程,會在影像進到 CDN 之前就把 metadata 抹掉。
fol. ix.r

如何檢驗一張角色卡

最快速的健全性檢查是 pngcheck,這個小型命令列工具是 PNG 驗證的事實標準。配合 -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

如果你看到影像尺寸卻沒有任何文字區塊,metadata 已經沒了。從這裡開始,用 Python 加 Pillow 做程式化檢驗大約只要五行:

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 解碼直接拋例外,那是某個第三方工具用原始 UTF-8 而不是 Base64 寫入了值——這是已知會出問題的模式。

fol. x.r

如何安全地保存與傳輸

一份簡短但有立場的檢查清單,讓你的角色卡撐過長途旅行:

  • 選擇會保留 chunk 的工具。 縮圖:Pillow 用 img.save(..., pnginfo=...);Sharp 用 .png({ force: true }).withMetadata() 再搭配手動拷貝 chunk。編輯器:優先挑明白標榜「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,就能擋下整類本來只有等使用者抱怨「匯入後是空的」才會浮現的悄悄退化。
fol. xi.r

一本工作冊,不再洗掉角色卡

tavernai.cards 替你處理上面所有事情:自動讀取 chara ccv3 區塊、把 JSON 同時驗證一遍 V2 與 V3 規範,並寫出一張能撐過任何合理重分發路徑的 PNG。如果你的角色卡庫一路上掉了 metadata,轉換器可以從 JSON 備份重建;如果某張卡看起來好好的,匯入某個平臺卻變空白,linter 會告訴你究竟是鏈條上的哪一步把它弄丟的。

別再一個一個使用者去除錯「匯入後是空的」這類問題。換上一個能告訴你哪一個 CDN、哪一個轉換器、哪一個編輯器剛剛吃掉你 tEXt 區塊的工作冊。

其他語言
English繁體中文 · 本頁日本語한국어