Project Files
src / sillyTavernPng.ts
/** PNG tEXt/zTXt keyword priority for SillyTavern-style character cards. */
const CARD_KEYWORD_PRIORITY = ["ccv3", "chara", "character"] as const;
export type CharacterCardFormat = "v2" | "v3" | "unknown";
export type CharacterCardDecodeOk = {
format: CharacterCardFormat;
keyword: (typeof CARD_KEYWORD_PRIORITY)[number];
json: unknown;
};
export type CharacterCardDecodeResult = CharacterCardDecodeOk | { error: string };
function inferFormat(json: unknown): CharacterCardFormat {
if (!json || typeof json !== "object") return "unknown";
const o = json as Record<string, unknown>;
if (o.spec === "chara_card_v3") return "v3";
if (typeof o.spec_version === "string" && o.spec_version.startsWith("3")) return "v3";
if (typeof o.name === "string" || typeof o.description === "string") return "v2";
return "unknown";
}
function findChunkText(
chunks: { tEXt: Array<{ keyword: string; text: string }>; zTXt: Array<{ keyword: string; text: string }> },
keyword: string,
): string | undefined {
const t = chunks.tEXt.find((e) => e.keyword === keyword);
if (t) return t.text;
const z = chunks.zTXt.find((e) => e.keyword === keyword);
return z?.text;
}
/**
* Decode SillyTavern / CC-style card from PNG text chunk lists (already decompressed for zTXt).
* Picks the first present keyword in order: ccv3, chara, character.
*/
export function decodeCharacterCardFromPngChunks(chunks: {
tEXt: Array<{ keyword: string; text: string }>;
zTXt: Array<{ keyword: string; text: string }>;
}): CharacterCardDecodeResult | null {
const errors: string[] = [];
for (const keyword of CARD_KEYWORD_PRIORITY) {
const text = findChunkText(chunks, keyword);
if (text == null || text.startsWith("<")) continue;
const trimmed = text.trim();
if (!trimmed) continue;
try {
const bin = Buffer.from(trimmed, "base64");
if (!bin.length) continue;
const utf8 = bin.toString("utf8");
const json = JSON.parse(utf8) as unknown;
return { format: inferFormat(json), keyword, json };
} catch (e) {
errors.push(`${keyword}: ${String(e)}`);
}
}
if (errors.length) return { error: errors.join("; ") };
return null;
}