| 153 | // ── PNG parsing ──────────────────────────────────────────────────── |
| 154 | |
| 155 | function parsePngCardBytes(buffer: ArrayBuffer): Record<string, unknown> { |
| 156 | const view = new DataView(buffer); |
| 157 | const bytes = new Uint8Array(buffer); |
| 158 | |
| 159 | // Verify PNG signature |
| 160 | if (bytes[0] !== 0x89 || bytes[1] !== 0x50 || bytes[2] !== 0x4e || bytes[3] !== 0x47) { |
| 161 | throw new Error('Not a valid PNG file'); |
| 162 | } |
| 163 | |
| 164 | const chunks: Record<string, string> = {}; |
| 165 | let offset = 8; // Skip PNG signature |
| 166 | |
| 167 | while (offset < buffer.byteLength) { |
| 168 | if (offset + 8 > buffer.byteLength) break; |
| 169 | |
| 170 | const length = view.getUint32(offset); |
| 171 | const chunkType = String.fromCharCode( |
| 172 | bytes[offset + 4], |
| 173 | bytes[offset + 5], |
| 174 | bytes[offset + 6], |
| 175 | bytes[offset + 7], |
| 176 | ); |
| 177 | offset += 8; |
| 178 | |
| 179 | if (offset + length + 4 > buffer.byteLength) break; |
| 180 | |
| 181 | if (chunkType === 'tEXt') { |
| 182 | const data = bytes.slice(offset, offset + length); |
| 183 | const nullIdx = data.indexOf(0); |
| 184 | if (nullIdx !== -1) { |
| 185 | const keyword = new TextDecoder('latin1').decode(data.slice(0, nullIdx)); |
| 186 | const text = new TextDecoder('latin1').decode(data.slice(nullIdx + 1)); |
| 187 | chunks[keyword] = text; |
| 188 | } |
| 189 | } |
| 190 | |
| 191 | offset += length + 4; // data + CRC |
| 192 | |
| 193 | if (chunkType === 'IEND') break; |
| 194 | } |
| 195 | |
| 196 | // Prefer ccv3 over chara |
| 197 | const raw = chunks['ccv3'] || chunks['chara']; |
| 198 | if (!raw) { |
| 199 | throw new Error('No character data found in PNG'); |
| 200 | } |
| 201 | |
| 202 | // Decode base64 → binary string → UTF-8 |
| 203 | const binaryStr = atob(raw); |
| 204 | const decoded = new Uint8Array(binaryStr.length); |
| 205 | for (let i = 0; i < binaryStr.length; i++) { |
| 206 | decoded[i] = binaryStr.charCodeAt(i); |
| 207 | } |
| 208 | const jsonStr = new TextDecoder('utf-8').decode(decoded); |
| 209 | return JSON.parse(jsonStr); |
| 210 | } |
| 211 | |
| 212 | // ── CharX (ZIP) parsing ──────────────────────────────────────────── |