PNG 角色卡 metadata:
tEXt 區塊、JSON 與匯入失敗。
一份給創作者的實戰指南——關於 PNG 的 tEXt 區塊:Tavern 角色卡格式如何把整份 JSON 藏進一張普通的影像裡、為什麼某些平臺、影像編輯器與 CDN 會默默地把它丟掉、以及該怎麼驗證、保留並重建被破壞的 metadata。
角色卡其實就是一張 PNG
用十六進位檢視器打開一張 Tavern 角色卡,最前面八個 byte 是 89 50 4E 47 0D 0A 1A 0A——標準的 PNG 簽名。用瀏覽器打開,看到的是一張肖像。把同一個檔案拖進 SillyTavern、RisuAI 或 Chub,整份 JSON——名字、敘述、開場白、世界書、system prompt——就和圖一起出現了。沒有附加檔,metadata 就藏在這張 PNG 本身裡,藏在任何 PNG 解碼器都必須無條件略過的位置。
那個位置就是 tEXt 區塊。看懂它是什麼、又是什麼東西在悄悄破壞它,就是「角色卡庫經得起每一次匯出、上傳與 CDN 重寫」與「同一張卡到了朋友的機器上只剩一張面無表情的肖像」之間的差別。
PNG 檔案到底是怎麼組成的
PNG 由固定 8 byte 的簽名加上一串區塊(chunk)組成,每個區塊的結構都一樣:
┌──────────┬──────────┬──────────────┬──────────┐ │ length │ type │ data │ CRC-32 │ │ 4 bytes │ 4 bytes │ length bytes │ 4 bytes │ └──────────┴──────────┴──────────────┴──────────┘
重點在於那四個 byte 的type。PNG 用每個字母的大小寫來描述處理規則:
- 首字母大寫 → 關鍵區塊(如 IHDR、IDAT、IEND),解碼器必須讀懂。
- 首字母小寫 → 輔助區塊,解碼器可以跳過,影像仍然能正常繪出。
- 第三個字母保留,目前一律大寫。
- 末字母小寫 → 安全可複製——即使編輯器不認識這個區塊也應該照樣保留;大寫則表示一旦編輯器對影像做了會讓區塊失效的修改,就必須丟棄它。
tEXt 是首字母小寫(輔助、可跳過)、末字母小寫(安全可複製)的區塊。理論上,它應該能在任何規矩的工具之間完整往返。實務上,「規矩」這兩個字才是承重牆。
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 的子集,不論讀取端怎麼嚴格解釋規範都不會出問題。
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 解碼器都搆得著。
metadata 是在哪幾個環節消失的
正因為 tEXt 是輔助區塊,任何重新編碼影像的工具在規範上都可以丟掉它,很多工具也確實這麼做。最常見的「角色卡到了目的地變空白」場景:
- 影像編輯器的「另存新檔」。 Photoshop、GIMP、Affinity、Pixelmator 對輔助區塊各有各的政策。預設設定多半會保留,但只要走到「匯出為 PNG」、「儲存為 Web 用」,或任何接到剝離型最佳化工具的路徑, tEXt 通常會被默默丟掉。
- 瀏覽器在貼上或下載時重編碼。 某些瀏覽器的截圖、canvas 匯出、背景抓圖流程會用平臺自帶的 PNG 寫入器重新編碼。透過 canvas 來回一次幾乎一定會弄丟 tEXt,因為 canvas API 根本沒有把它帶過去的途徑。
- CDN 的影像最佳化。 Cloudflare Polish、Vercel Image Optimization、Cloudinary auto-format 這類服務把輔助區塊當成累贅,會主動重壓並剝掉任何渲染上用不到的東西。如果你的角色卡圖片是透過 /_next/image 或啟用了 Polish 的 CDN 提供的,訪客最終下載到的檔案,不是你上傳的那一份。
- WebP / AVIF 轉檔。 WebP 與 AVIF 各有自己的 metadata 容器(EXIF、XMP),但絕大多數轉檔器並不會把 PNG 的 tEXt 轉換進去。任何自動把上傳影像轉成 WebP/AVIF 來分發的管線,除非你明確關掉,否則一定會把嵌入的角色卡 JSON 弄不見。
- 平臺主動清洗。 部分社群平臺會把上傳影像送進一個專門清掉 EXIF GPS 等隱私敏感欄位的清洗器。這類清洗器往往對 PNG chunk 一視同仁,把所有輔助區塊一併移除。角色卡在 Discord、Twitter 或某個隱私代理之間繞一圈,到了另一端就被剝光了。
- 縮圖函式庫沒有複製輔助區塊。 Sharp、ImageMagick 等工具都能保留 tEXt,但多數管線並不會明確啟用。一個用預設參數呼叫 Sharp 來產生縮圖的流程,會在影像進到 CDN 之前就把 metadata 抹掉。
如何檢驗一張角色卡
最快速的健全性檢查是 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 寫入了值——這是已知會出問題的模式。
如何安全地保存與傳輸
一份簡短但有立場的檢查清單,讓你的角色卡撐過長途旅行:
- 選擇會保留 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,就能擋下整類本來只有等使用者抱怨「匯入後是空的」才會浮現的悄悄退化。
一本工作冊,不再洗掉角色卡
tavernai.cards 替你處理上面所有事情:自動讀取 chara 與 ccv3 區塊、把 JSON 同時驗證一遍 V2 與 V3 規範,並寫出一張能撐過任何合理重分發路徑的 PNG。如果你的角色卡庫一路上掉了 metadata,轉換器可以從 JSON 備份重建;如果某張卡看起來好好的,匯入某個平臺卻變空白,linter 會告訴你究竟是鏈條上的哪一步把它弄丟的。