UNPKG

@bitblit/asyncglk

Version:
294 lines (290 loc) 10.9 kB
/* Blorb Files and functions ========================= Copyright (c) 2023 Dannii Willis MIT licenced https://github.com/curiousdannii/asyncglk */ import { unescape } from 'lodash-es'; import { FileView, utf8decoder } from '../common/misc.js'; import { IFF } from './iff.js'; const BLORB_RESOURCE_INDEX_USAGES = { Data: 'data', Exec: 'exec', Pict: 'pict', 'Snd ': 'sound', }; const UNKNOWN_IMAGE_TYPE = '????'; export class Blorb { classname = 'Blorb'; /** Chunks are indexed with "USE:NUMBER" keys */ chunks = {}; coverimagenum; debugdata; is_inited = false; metadata = {}; constructor(data) { if (data) { this.init(data); } } init(data, options) { if (this.is_inited) { return; } if (data instanceof Uint8Array) { const iff = new IFF(); iff.parse(data); if (iff.type !== 'IFRS') { throw new Error('Not a Blorb file'); } if (iff.chunks[0].type !== 'RIdx') { throw new Error('Malformed blorb: chunk 1 is not RIdx'); } // Process the chunks for (const iff_chunk of iff.chunks) { switch (iff_chunk.type) { case 'Dbug': { // Process the debug chunk this.debugdata = iff_chunk.data; break; } case 'Fspc': { // Process the frontispiece chunk const view = new FileView(iff_chunk.data); this.coverimagenum = view.getUint32(0); break; } case 'IFmd': { const xml = utf8decoder.decode(iff_chunk.data); // Normally parsing XML with regexes is super unwise, but the Babel spec places enough restrictions on the metadata XML that this should be safe const bibliographic = /<bibliographic>(.+)<\/bibliographic>/is.exec(xml); const entry_pattern = /<(\w+)>(.+)<\/\1>/gi; const linebreak_pattern = /<br\/>/g; const whitespace_pattern = /\s+/g; if (bibliographic) { let result; while ((result = entry_pattern.exec(bibliographic[1]))) { const tag = result[1].toLowerCase(); let content = result[2].replace(whitespace_pattern, ' '); if (tag === 'description') { content = content.replace(linebreak_pattern, '\n'); } this.metadata[tag] = unescape(content); } } break; } case 'RDes': { // Process the resource description chunk const view = new FileView(iff_chunk.data); let i = 4; while (i < view.byteLength) { const usage = BLORB_RESOURCE_INDEX_USAGES[view.getFourCC(i)]; const resource_number = view.getUint32(i + 4); const text_length = view.getUint32(i + 8); const chunk = this.chunks[`${usage}:${resource_number}`]; if (chunk) { chunk.alttext = utf8decoder.decode(view.getUint8Subarray(i + 12, text_length)); } i += 12 + text_length; } break; } case 'RIdx': { // Process the resource index chunk const view = new FileView(iff_chunk.data); let i = 4; while (i < view.byteLength) { const usage = view.getFourCC(i); const resource_number = view.getUint32(i + 4); const resource_offset = view.getUint32(i + 8); i += 12; const resource_chunk = iff.chunks.filter(chunk => chunk.offset === resource_offset)[0]; if (!resource_chunk) { throw new Error(`No Blorb chunk at offset ${resource_offset}`); } const type = resource_chunk.type; const chunk = { blorbtype: type, // Embedded FORM chunks need to include the chunk headers for resource streams, so patch them now content: type === 'FORM' ? data.subarray(resource_chunk.offset, resource_chunk.offset + 8 + resource_chunk.data.length) : resource_chunk.data, usage: BLORB_RESOURCE_INDEX_USAGES[usage], }; if (usage === 'Pict') { if (type === 'JPEG') { chunk.type = 'jpeg'; } else if (type === 'PNG ') { chunk.type = 'png'; } else { chunk.type = UNKNOWN_IMAGE_TYPE; } } if (usage === 'Data') { chunk.binary = type === 'BINA' || type === 'FORM'; } this.chunks[`${chunk.usage}:${resource_number}`] = chunk; } break; } } } } // Process the infomap format else if (options?.format === 'infomap') { for (const key in data) { if (!Number.isInteger(+key)) { continue; } const chunk = Object.assign({}, data[key]); delete chunk.image; if (chunk.height && chunk.width) { chunk.imagesize = { height: chunk.height, width: chunk.width, }; delete chunk.height; delete chunk.width; } chunk.type = chunk.url.endsWith('.png') ? 'png' : 'jpeg'; chunk.usage = 'pict'; this.chunks[`pict:${key}`] = chunk; } } else if (Array.isArray(data) && data.length === 0) { // Empty data, do nothing } else { throw new Error('Unsupported Blorb.init data format'); } this.is_inited = true; } getlibrary() { return null; } get_chunk(usage, num) { return this.chunks[`${usage}:${num}`] || null; } get_cover_pict() { return this.coverimagenum ?? null; } get_data_chunk(num) { const chunk = this.chunks[`data:${num}`]; if (!chunk?.content) { return null; } return { binary: chunk.binary, data: chunk.content, }; } get_debug_info() { return this.debugdata; } /** Return the game file chunk, or null if there isn't one. * If type is provided (GLUL or ZCOD) then the game file is checked to ensure it matches */ get_exec_data(gametype) { const chunk = this.chunks['exec:0']; if (!chunk?.content || (gametype && chunk.blorbtype !== gametype)) { return null; } return chunk.content; } get_image_info(num) { const chunk = this.chunks[`pict:${num}`]; if (!chunk) { return null; } // Try to extract the image sizes if (!chunk.imagesize && chunk.content) { if (chunk.type === 'jpeg') { chunk.imagesize = get_jpeg_dimensions(chunk.content); } else if (chunk.type === 'png') { chunk.imagesize = get_png_dimensions(chunk.content); } } const img = Object.assign({ image: num }, chunk); if (img.imagesize) { img.height = img.imagesize.height; img.width = img.imagesize.width; } return img; } get_image_url(num) { const chunk = this.chunks[`pict:${num}`]; if (!chunk) { return null; } if (chunk.url) { return chunk.url; } if (chunk.type !== UNKNOWN_IMAGE_TYPE && chunk.content) { chunk.url = URL.createObjectURL(new Blob([chunk.content], { type: `image/${chunk.type}` })); return chunk.url; } return null; } get_metadata(field) { return this.metadata[field]; } inited() { return this.is_inited; } } export function is_blorb(data) { const view = new FileView(data); return view.getFourCC(0) === 'FORM' && view.getFourCC(8) === 'IFRS'; } function get_jpeg_dimensions(data) { const view = new FileView(data); let i = 0; while (i < view.byteLength) { if (view.getUint8(i) !== 0xFF) { return; } while (view.getUint8(i) === 0xFF) { i++; } const marker = view.getUint8(i++); // Markers with no data if (marker === 0x01 || (marker >= 0xD0 && marker <= 0xD9)) { continue; } const chunklength = view.getUint16(i); if (marker >= 0xC0 && marker <= 0xCF && marker !== 0xC8) { if (chunklength < 7) { // SOF block is too small return; } return { height: view.getUint16(i + 3), width: view.getUint16(i + 5), }; } i += chunklength; } } function get_png_dimensions(data) { const view = new FileView(data); if (view.getFourCC(0) !== '\x89PNG') { return; } let i = 8; while (i < view.byteLength) { const chunklength = view.getUint32(i); const chunktype = view.getFourCC(i + 4); i += 8; if (chunktype === 'IHDR') { return { height: view.getInt32(i + 4), width: view.getInt32(i), }; } // Skip CRC i += chunklength + 4; } }