import { inflateSync } from 'node:zlib' import type { ParsedCard } from '#shared/types' const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]) /** 读取 PNG 中所有文本块(tEXt / zTXt / iTXt),返回 关键字 → 文本 */ export function readPngTextChunks(buf: Buffer): Record { if (buf.length < 8 || !buf.subarray(0, 8).equals(PNG_SIGNATURE)) throw new Error('不是有效的 PNG 文件') const out: Record = {} let off = 8 while (off + 8 <= buf.length) { const len = buf.readUInt32BE(off) const type = buf.toString('ascii', off + 4, off + 8) const data = buf.subarray(off + 8, off + 8 + len) if (type === 'tEXt') { const sep = data.indexOf(0) if (sep > 0) out[data.toString('latin1', 0, sep).toLowerCase()] = data.toString('latin1', sep + 1) } else if (type === 'zTXt') { const sep = data.indexOf(0) if (sep > 0) out[data.toString('latin1', 0, sep).toLowerCase()] = inflateSync(data.subarray(sep + 2)).toString('latin1') } else if (type === 'iTXt') { // keyword\0 compressionFlag compressionMethod languageTag\0 translatedKeyword\0 text const sep = data.indexOf(0) if (sep > 0) { const keyword = data.toString('latin1', 0, sep).toLowerCase() const compressed = data[sep + 1] === 1 const langEnd = data.indexOf(0, sep + 3) const transEnd = data.indexOf(0, langEnd + 1) const body = data.subarray(transEnd + 1) out[keyword] = (compressed ? inflateSync(body) : body).toString('utf8') } } if (type === 'IEND') break off += 12 + len } return out } function decodeCardText(text: string): Record { const trimmed = text.trim() if (trimmed.startsWith('{')) return JSON.parse(trimmed) return JSON.parse(Buffer.from(trimmed, 'base64').toString('utf8')) } /** 从 PNG 或 JSON 中提取原始角色卡对象 */ export function extractRawCard(buf: Buffer, filename = ''): Record { const isPng = buf.subarray(0, 8).equals(PNG_SIGNATURE) if (isPng) { const chunks = readPngTextChunks(buf) // V3 卡优先读 ccv3,同时兼容只写了 chara 的 V2/V1 卡 const raw = chunks.ccv3 ?? chunks.chara if (!raw) throw new Error('这张 PNG 里没有角色卡数据(缺少 chara / ccv3 文本块)。如果图片经过截图、压缩或聊天软件转发,元数据会丢失,请使用原始文件。') return decodeCardText(raw) } const text = buf.toString('utf8').replace(/^/, '') try { return JSON.parse(text) } catch { throw new Error(`无法解析文件 ${filename}:既不是 PNG 角色卡也不是 JSON`) } } const str = (v: unknown) => (typeof v === 'string' ? v : v == null ? '' : String(v)) const strArr = (v: unknown) => (Array.isArray(v) ? v.map(str).filter(Boolean) : []) /** 将 V1 / V2 / V3 角色卡统一成内部结构 */ export function normalizeCard(raw: Record): ParsedCard { const specRaw = str(raw.spec) const hasData = raw.data && typeof raw.data === 'object' const d = (hasData ? raw.data : raw) as Record const spec: ParsedCard['spec'] = specRaw === 'chara_card_v3' ? 'v3' : specRaw === 'chara_card_v2' || hasData ? 'v2' : 'v1' const ext = (d.extensions ?? {}) as Record const book = (d.character_book ?? null) as { entries?: Record[] } | null const lore = (book?.entries ?? []).map((e, i) => { const extE = (e.extensions ?? {}) as Record return { title: str(e.comment) || str(e.name) || `条目 ${i + 1}`, keys: [...strArr(e.keys), ...strArr(e.secondary_keys)], content: str(e.content), constant: Boolean(e.constant), enabled: e.enabled !== false && extE.disable !== true, order: Number(e.insertion_order ?? e.priority ?? 100) || 100 } }).filter(e => e.content.trim()) return { spec, name: str(d.name) || str(raw.name) || '未命名角色', description: str(d.description), personality: str(d.personality), scenario: str(d.scenario), firstMes: str(d.first_mes), mesExample: str(d.mes_example), systemPrompt: str(d.system_prompt), postHistoryInstructions: str(d.post_history_instructions), creatorNotes: str(d.creator_notes) || str(raw.creatorcomment), alternateGreetings: strArr(d.alternate_greetings), tags: strArr(d.tags), world: typeof ext.world === 'string' ? ext.world : '', lore, raw } } /** 替换酒馆宏:{{char}} / {{user}} / / */ export function replaceMacros(text: string, charName: string, userName: string) { if (!text) return text return text .replace(/\{\{\s*char\s*\}\}|/gi, charName) .replace(/\{\{\s*user\s*\}\}|/gi, userName || '主角') .replace(/\r\n/g, '\n') } export function parseCardFile(buf: Buffer, filename = '') { return normalizeCard(extractRawCard(buf, filename)) }