#!/usr/bin/env python # blorbtool.py: A (semi-)multifunctional Blorb utility # Created by Andrew Plotkin (erkyrath@eblong.com) # Last updated: October 10, 2024 # This script is in the public domain. # When listing chunks, you'll see output that looks like: # 'GLUL' (232192 bytes, start 60) # "60" means that the IFF chunk starts at byte 60 in the blorb file. There's # always an eight-byte header, so the actual Glulx data file starts at byte # 68 (and is then 232192 bytes long). # # For AIFF chunks, you'll see: # 'FORM'/'AIFF' (8536+8 bytes, start 324266) # The AIFF data implicitly includes the eight-byte header, which is why the # length says "+8". Start at byte 324266 and read 8544 bytes. # We use the print() function for Python 2/3 compatibility from __future__ import print_function # We use the Py2 raw_input() function. In Py3 there is no such function, # but we define a back-polyfill. (I'm lazy.) try: raw_input except NameError: raw_input = input import sys import os import optparse import re import collections import struct import base64 import json try: import readline except: pass try: # Python 3.3 and up os_replace = os.replace except AttributeError: if (os.name != 'nt'): # Older Python (on Unix) os_replace = os.rename else: # On Windows, os.rename can't replace an existing file. def os_replace(src, dst): try: os.remove(dst) except: pass os.rename(src, dst) popt = optparse.OptionParser(usage='blorbtool.py BLORBFILE [ command ]') popt.add_option('-n', '--new', action='store_true', dest='newfile', help='create a new blorb file instead of loading one in') popt.add_option('-o', '--output', action='store', dest='output', metavar='BLORBFILE', help='blorb file to write to (if requested)') popt.add_option('-f', '--force', action='store_true', dest='force', help='overwrite files without confirming') popt.add_option('-v', '--verbose', action='store_true', dest='verbose', help='verbose stack traces on error') popt.add_option('-l', '--commands', action='store_true', dest='listcommands', help='list all commands (and exit)') (opts, args) = popt.parse_args() def dict_append(map, key, val): ls = map.get(key) if (not ls): ls = [] map[key] = ls ls.append(val) def confirm_input(prompt): ln = raw_input(prompt+' >') if (ln.lower().startswith('y')): return True class BlorbChunk: def __init__(self, blorbfile, typ, start, len, formtype=None): self.blorbfile = blorbfile self.type = typ self.start = start self.len = len self.formtype = formtype self.literaldata = None self.filedata = None self.filestart = None def __repr__(self): return '' % (typestring(self.type), self.start, self.len) def data(self, max=None): if (self.literaldata): if (max is not None): return self.literaldata[0:max] else: return self.literaldata if (self.filedata): fl = open(self.filedata, 'rb') if (self.filestart is not None): fl.seek(self.filestart) if (max is not None): dat = fl.read(max) else: dat = fl.read() fl.close() return dat self.blorbfile.formchunk.seek(self.start) toread = self.len if (max is not None): toread = min(self.len, max) return self.blorbfile.formchunk.read(toread) def describe(self): if (not self.formtype): return '%s (%d bytes, start %d)' % (typestring(self.type), self.len, self.start) else: return '%s/%s (%d+8 bytes, start %d)' % (typestring(self.type), typestring(self.formtype), self.len, self.start) def display(self): print('* %s' % (self.describe(),)) if (self.type == b'RIdx'): # Index chunk dat = self.data() (subdat, dat) = (dat[:4], dat[4:]) num = struct.unpack('>I', subdat)[0] print('%d resources:' % (num,)) while (dat): (subdat, dat) = (dat[:12], dat[12:]) subls = struct.unpack('>4c2I', subdat) usage = b''.join(subls[0:4]) print(' %s %d: starts at %d' % (typestring(usage), subls[-2], subls[-1])) elif (self.type == b'IFmd'): # Metadata chunk dat = self.data() print(dat.decode('utf-8')) elif (self.type == b'Fspc'): # Frontispiece chunk dat = self.data() if (len(dat) != 4): print('Warning: invalid contents!') else: num = struct.unpack('>I', dat[0:4])[0] print('Frontispiece is pict number', num) elif (self.type == b'RDes'): # Resource description chunk dat = self.data() (subdat, dat) = (dat[:4], dat[4:]) count = struct.unpack('>I', subdat)[0] print('%d entries:' % (count,)) for ix in range(count): if (len(dat) < 12): print('Warning: contents too short!') break (subdat, dat) = (dat[:12], dat[12:]) subls = struct.unpack('>4c2I', subdat) restype = b''.join(subls[0:4]) strlen = subls[-1] num = subls[-2] if (len(dat) < strlen): print('Warning: contents too short!') break (subdat, dat) = (dat[:strlen], dat[strlen:]) print(' %s resource %d: "%s"' % (typestring(restype), num, subdat.decode('utf-8'))) if (len(dat) > 0): print('Warning: contents too long!') elif (self.type == b'APal'): # Adaptive palette dat = self.data() if (len(dat) % 4 != 0): print('Warning: invalid contents!') else: ls = [] while (dat): (subdat, dat) = (dat[:4], dat[4:]) num = struct.unpack('>I', subdat)[0] ls.append(str(num)) print('Picts using adaptive palette:', ' '.join(ls)) elif (self.type == b'Loop'): # Looping dat = self.data() if (len(dat) % 8 != 0): print('Warning: invalid contents!') else: while (dat): (subdat, dat) = (dat[:8], dat[8:]) (num, count) = struct.unpack('>II', subdat) print('Sound %d repeats %d times' % (num, count)) elif (self.type == b'RelN'): # Release number dat = self.data() if (len(dat) != 2): print('Warning: invalid contents!') else: num = struct.unpack('>H', dat)[0] print('Release number', num) elif (self.type == b'SNam'): # Story name (obsolete) dat = self.data() if (len(dat) % 2 != 0): print('Warning: invalid contents!') else: ls = [] while (dat): (subdat, dat) = (dat[:2], dat[2:]) num = struct.unpack('>H', subdat)[0] ls.append(chr(num)) print('Story name:', ''.join(ls)) elif (self.type in (b'TEXT', b'ANNO', b'AUTH', b'(c) ')): dat = self.data() print(dat.decode()) elif (self.type == b'Reso'): # Resolution chunk dat = self.data() if (len(dat)-24) % 28 != 0: print('Warning: invalid contents!') else: (subdat, dat) = (dat[:24], dat[24:]) subls = struct.unpack('>6I', subdat) print('Standard window size %dx%d, min %dx%d, max %dx%d' % subls) while (dat): (subdat, dat) = (dat[:28], dat[28:]) subls = struct.unpack('>7I', subdat) print('Pict %d: standard ratio: %d/%d, min %d/%d, max %d/%d' % subls) else: dat = self.data(16) strdat = repr(dat) if (re.match('[a-z][\'\"]', strdat)): strdat = strdat[1:] if (len(dat) == self.len): print('contents: %s' % (strdat,)) else: print('beginning: %s' % (strdat,)) class BlorbFile: def __init__(self, filename, outfilename=None): self.chunks = [] self.chunkmap = {} self.chunkatpos = {} self.usages = [] self.usagemap = {} self.filename = filename self.outfilename = outfilename if (not self.outfilename): self.outfilename = self.filename if (not self.filename): # No loading; create an empty file. self.file = None self.formchunk = None self.changed = True chunk = BlorbChunk(self, b'RIdx', -1, 4) chunk.literaldata = struct.pack('>I', 0) self.add_chunk(chunk, None, None, 0) return self.changed = False self.file = open(filename, 'rb') formchunk = Chunk(self.file) self.formchunk = formchunk if (formchunk.getname() != b'FORM'): raise Exception('This does not appear to be a Blorb file.') formtype = formchunk.read(4) if (formtype != b'IFRS'): raise Exception('This does not appear to be a Blorb file.') formlen = formchunk.getsize() while formchunk.tell() < formlen: chunk = Chunk(formchunk) start = formchunk.tell() size = chunk.getsize() formtype = None if chunk.getname() == b'FORM': formtype = chunk.read(4) subchunk = BlorbChunk(self, chunk.getname(), start, size, formtype) self.chunks.append(subchunk) chunk.skip() chunk.close() for chunk in self.chunks: self.chunkatpos[chunk.start] = chunk dict_append(self.chunkmap, chunk.type, chunk) # Sanity checks. Also get the usage list. ls = self.chunkmap.get(b'RIdx') if (not ls): raise Exception('No resource index chunk!') elif (len(ls) != 1): print('Warning: too many resource index chunks!') else: chunk = ls[0] if (self.chunks[0] is not chunk): print('Warning: resource index chunk is not first!') dat = chunk.data() numres = struct.unpack('>I', dat[0:4])[0] if (numres*12+4 != chunk.len): print('Warning: resource index chunk has wrong size!') for ix in range(numres): subdat = dat[4+ix*12 : 16+ix*12] typ = struct.unpack('>4c', subdat[0:4]) typ = b''.join(typ) num = struct.unpack('>I', subdat[4:8])[0] start = struct.unpack('>I', subdat[8:12])[0] subchunk = self.chunkatpos.get(start) if (not subchunk): print('Warning: resource (%s, %d) refers to a nonexistent chunk!' % (typestring(typ), num)) self.usages.append( (typ, num, subchunk) ) self.usagemap[(typ, num)] = subchunk def close(self): if (self.formchunk): self.formchunk.close() self.formchunk = None if (self.file): self.file.close() self.file = None def sanity_check(self): if (len(self.usages) != len(self.usagemap)): print('Warning: internal mismatch (usages)!') if (len(self.chunks) != len(self.chunkatpos)): print('Warning: internal mismatch (chunks)!') def chunk_position(self, chunk): try: return self.chunks.index(chunk) except: return None def save_if_needed(self): if self.changed: try: self.save() except CommandError as ex: print(str(ex)) def canonicalize(self): self.sanity_check() try: indexchunk = self.chunkmap[b'RIdx'][0] except: raise CommandError('There is no index chunk, so this cannot be a legal blorb file.') indexchunk.len = 4 + 12*len(self.usages) pos = 12 for chunk in self.chunks: chunk.savestart = pos pos = pos + 8 + chunk.len if (pos % 2): pos = pos+1 self.usages.sort(key=lambda tup:tup[2].savestart) ls = [] ls.append(struct.pack('>I', len(self.usages))) for (typ, num, chunk) in self.usages: ls.append(typ) ls.append(struct.pack('>II', num, chunk.savestart)) dat = b''.join(ls) if (len(dat) != indexchunk.len): print('Warning: index chunk length does not match!') indexchunk.literaldata = dat def save(self, outfilename=None): if (outfilename): self.outfilename = outfilename if (not self.changed and (self.outfilename == self.filename)): raise CommandError('No changes need saving.') if (not self.outfilename): raise CommandError('No pathname supplied for saving.') if (os.path.exists(self.outfilename) and not opts.force): if (not confirm_input('File %s exists. Rewrite?' % (self.outfilename,))): print('Cancelled.') return self.canonicalize() tmpfilename = self.outfilename + '~TEMP' fl = open(tmpfilename, 'wb') fl.write(b'FORM----IFRS') pos = 12 for chunk in self.chunks: fl.write(chunk.type) fl.write(struct.pack('>I', chunk.len)) pos = pos+8 dat = chunk.data() fl.write(dat) pos = pos+len(dat) if (pos % 2): fl.write(b'\0') pos = pos+1 fl.seek(4) fl.write(struct.pack('>I', pos-8)) fl.close() os_replace(tmpfilename, self.outfilename) print('Wrote file:', self.outfilename) return self.outfilename def delete_chunk(self, delchunk): self.chunks = [ chunk for chunk in self.chunks if (chunk is not delchunk) ] ls = self.chunkmap[delchunk.type] ls = [ chunk for chunk in ls if (chunk is not delchunk) ] if (ls): self.chunkmap[delchunk.type] = ls else: self.chunkmap.pop(delchunk.type) self.chunkatpos.pop(delchunk.start) self.usages = [ tup for tup in self.usages if (tup[2] is not delchunk) ] ls = [ key for (key,val) in self.usagemap.items() if (val is delchunk) ] for key in ls: self.usagemap.pop(key) self.changed = True def add_chunk(self, chunk, use=None, num=None, pos=None): if (pos is None): self.chunks.append(chunk) else: self.chunks.insert(pos, chunk) self.chunkatpos[chunk.start] = chunk dict_append(self.chunkmap, chunk.type, chunk) if (use is not None): self.usages.append( (use, num, chunk) ) self.usagemap[(use,num)] = chunk self.changed = True class CommandError(Exception): pass class Chunk: """This is a copy of the Python standard library "chunk" class, as shipped in Python 3.12.7. The module is due to be removed from Python 3.13 so we need to stash it here. This class is copyright by the Python Software Foundation, PSF License v2. """ def __init__(self, file, align=True, bigendian=True, inclheader=False): self.closed = False self.align = align # whether to align to word (2-byte) boundaries if bigendian: strflag = '>' else: strflag = '<' self.file = file self.chunkname = file.read(4) if len(self.chunkname) < 4: raise EOFError try: self.chunksize = struct.unpack_from(strflag+'L', file.read(4))[0] except struct.error: raise EOFError from None if inclheader: self.chunksize = self.chunksize - 8 # subtract header self.size_read = 0 try: self.offset = self.file.tell() except (AttributeError, OSError): self.seekable = False else: self.seekable = True def getname(self): """Return the name (ID) of the current chunk.""" return self.chunkname def getsize(self): """Return the size of the current chunk.""" return self.chunksize def close(self): if not self.closed: try: self.skip() finally: self.closed = True def isatty(self): if self.closed: raise ValueError("I/O operation on closed file") return False def seek(self, pos, whence=0): """Seek to specified position into the chunk. Default position is 0 (start of chunk). If the file is not seekable, this will result in an error. """ if self.closed: raise ValueError("I/O operation on closed file") if not self.seekable: raise OSError("cannot seek") if whence == 1: pos = pos + self.size_read elif whence == 2: pos = pos + self.chunksize if pos < 0 or pos > self.chunksize: raise RuntimeError self.file.seek(self.offset + pos, 0) self.size_read = pos def tell(self): if self.closed: raise ValueError("I/O operation on closed file") return self.size_read def read(self, size=-1): """Read at most size bytes from the chunk. If size is omitted or negative, read until the end of the chunk. """ if self.closed: raise ValueError("I/O operation on closed file") if self.size_read >= self.chunksize: return b'' if size < 0: size = self.chunksize - self.size_read if size > self.chunksize - self.size_read: size = self.chunksize - self.size_read data = self.file.read(size) self.size_read = self.size_read + len(data) if self.size_read == self.chunksize and \ self.align and \ (self.chunksize & 1): dummy = self.file.read(1) self.size_read = self.size_read + len(dummy) return data def skip(self): """Skip the rest of the chunk. If you are not interested in the contents of the chunk, this method should be called so that the file points to the start of the next chunk. """ if self.closed: raise ValueError("I/O operation on closed file") if self.seekable: try: n = self.chunksize - self.size_read # maybe fix alignment if self.align and (self.chunksize & 1): n = n + 1 self.file.seek(n, 1) self.size_read = self.size_read + n return except OSError: pass while self.size_read < self.chunksize: n = min(8192, self.chunksize - self.size_read) dummy = self.read(n) if not dummy: raise EOFError class BlorbTool: def show_commands(): print('blorbtool commands:') print() print('list -- list all chunks') print('index -- list all resources in the index chunk') print('display -- display contents of all chunks') print('display TYPE -- contents of chunk(s) of that type') print('display USE NUM -- contents of chunk by use and number (e.g., "display Exec 0")') print('export TYPE FILENAME -- export the chunk of that type to a file') print('export USE NUM FILENAME -- export a chunk by use and number') print('import TYPE FILENAME -- import a file as a chunk of that type') print('import USE NUM TYPE FILENAME -- import a file as a resource of that use, number, and type') print('delete TYPE -- delete chunk(s) of that type') print('delete USE NUM -- delete chunk by use and number') print('giload DIRECTORY -- export the Exec and Pict chunks for use with Quixe') print('save -- write out changes') print('reload -- discard changes and reload existing blorb file') show_commands = staticmethod(show_commands) def __init__(self): self.is_interactive = False self.has_quit = False def set_interactive(self, val): self.is_interactive = val def quit_yet(self): return self.has_quit def handle(self, args=None): try: if (self.is_interactive): args = raw_input('>').split() if (not args): return argname = args.pop(0) if (argname in self.aliasmap): argname = self.aliasmap[argname] cmd = getattr(self, 'cmd_'+argname, None) if (not cmd): raise CommandError('Unknown command: ' + argname) return cmd(args) except KeyboardInterrupt: # EOF or interrupt. Pass it on. raise except EOFError: # EOF or interrupt. Pass it on. raise except CommandError as ex: print(str(ex)) except Exception as ex: # Unexpected exception: print it. print(ex.__class__.__name__+':', str(ex)) if (opts.verbose): raise def parse_int(self, val, label=''): if (label): label = label+': ' try: return int(val) except: raise CommandError(label+'integer required') def parse_chunk_type(self, val, label=''): if (label): label = label+': ' if len(val) > 4: raise CommandError(label+'chunk type must be 1-4 characters') return val.ljust(4).encode() aliasmap = { '?':'help', 'q':'quit', 'write':'save', 'restart':'reload', 'restore':'reload' } def cmd_quit(self, args): if (args): raise CommandError('usage: quit') self.has_quit = True def cmd_help(self, args): if (args): raise CommandError('usage: help') self.show_commands() def cmd_list(self, args): if (args): raise CommandError('usage: list') print(len(blorbfile.chunks), 'chunks:') for chunk in blorbfile.chunks: print(' %s' % (chunk.describe(),)) def cmd_index(self, args): if (args): raise CommandError('usage: index') print(len(blorbfile.usages), 'resources:') for (use, num, chunk) in blorbfile.usages: print(' %s %d: %s' % (typestring(use), num, chunk.describe())) def cmd_display(self, args): if (not args): ls = blorbfile.chunks elif (len(args) == 1): typ = self.parse_chunk_type(args[0], 'display') ls = [ chunk for chunk in blorbfile.chunks if chunk.type == typ ] if (not ls): raise CommandError('No chunks of type %s' % (typestring(typ),)) elif (len(args) == 2): use = self.parse_chunk_type(args[0], 'display') num = self.parse_int(args[1], 'display (second argument)') chunk = blorbfile.usagemap.get( (use, num) ) if (not chunk): raise CommandError('No resource with usage %s, number %d' % (typestring(use), num)) ls = [ chunk ] else: raise CommandError('usage: display | display TYPE | display USE NUM') for chunk in ls: chunk.display() def cmd_export(self, args): if (len(args) == 2): typ = self.parse_chunk_type(args[0], 'export') ls = [ chunk for chunk in blorbfile.chunks if chunk.type == typ ] if (not ls): raise CommandError('No chunks of type %s' % (typestring(typ),)) if (len(ls) != 1): raise CommandError('%d chunks of type %s' % (len(ls), typestring(typ),)) chunk = ls[0] elif (len(args) == 3): use = self.parse_chunk_type(args[0], 'export') num = self.parse_int(args[1], 'export (second argument)') chunk = blorbfile.usagemap.get( (use, num) ) if (not chunk): raise CommandError('No resource with usage %s, number %d' % (typestring(use), num)) else: raise CommandError('usage: export TYPE FILENAME | export USE NUM FILENAME') outfilename = args[-1] if (outfilename == blorbfile.filename): raise CommandError('You can\'t export a chunk over the original blorb file!') if (os.path.exists(outfilename) and not opts.force): if (not confirm_input('File %s exists. Overwrite?' % (outfilename,))): print('Cancelled.') return outfl = open(outfilename, 'wb') if (chunk.formtype and chunk.formtype != b'FORM'): # For an AIFF file, we must include the FORM/length header. # (Unless it's an overly nested AIFF.) outfl.write(b'FORM') outfl.write(struct.pack('>I', chunk.len)) outfl.write(chunk.data()) finallen = outfl.tell() outfl.close() print('Wrote %d bytes to %s.' % (finallen, outfilename)) def cmd_import(self, args): origchunk = None if (len(args) == 2): typ = self.parse_chunk_type(args[0], 'import') use = None num = None ls = [ chunk for chunk in blorbfile.chunks if chunk.type == typ ] if (ls): origchunk = ls[0] elif (len(args) == 4): use = self.parse_chunk_type(args[0], 'import') num = self.parse_int(args[1], 'import (second argument)') typ = self.parse_chunk_type(args[2], 'import (third argument)') origchunk = blorbfile.usagemap.get( (use, num) ) else: raise CommandError('usage: import TYPE FILENAME | import USE NUM TYPE FILENAME') infilename = args[-1] if (infilename == blorbfile.filename): raise CommandError('You can\'t import the original blorb file as a chunk!') fl = open(infilename, 'rb') filestart = None formtype = None dat = fl.read(5) if (dat[0:4] == b'FORM' and bytes_to_intarray(dat)[4] < 0x20): # This is an AIFF file, and must be embedded filestart = 8 fl.seek(8, 0) formtype = fl.read(4) if (typ != b'FORM'): # We accept the formtype as a synonym here, if the user # got it right. if (typ != formtype): raise CommandError('This IFF file has form type \'%s\', not \'%s\'.' % (formtype, typ)) typ = b'FORM' fl.seek(0, 2) filelen = fl.tell() fl.close() if (filestart): filelen = filelen - 8 fakestart = min(list(blorbfile.chunkatpos.keys()) + [0]) - 1 if origchunk: # Replace existing chunk pos = blorbfile.chunk_position(origchunk) blorbfile.delete_chunk(origchunk) else: pos = None chunk = BlorbChunk(blorbfile, typ, fakestart, filelen) chunk.filedata = infilename if (filestart): chunk.filestart = filestart chunk.formtype = formtype blorbfile.add_chunk(chunk, use, num, pos) if pos is None: print('Added chunk, length %d' % (filelen,)) else: print('Replaced chunk, new length %d' % (filelen,)) def cmd_giload(self, args): prefix = '' if (len(args) == 1): outdirname = args[0] elif (len(args) == 2): outdirname = args[0] prefix = args[1] else: raise CommandError('usage: giload DIRECTORY | giload DIRECTORY PREFIX') if (not (os.path.exists(outdirname) and os.path.isdir(outdirname))): raise CommandError('Not a directory: %s' % (outdirname)) chunk = blorbfile.usagemap.get( (b'Exec', 0) ) if (not chunk): raise CommandError('No resource with usage %s, number %d' % (typestring(use), num)) chunkdat = chunk.data() if (chunk.formtype and chunk.formtype != b'FORM'): chunkdat = b'FORM' + struct.pack('>I', chunk.len) + chunkdat outfl = open(os.path.join(outdirname, 'game.ulx.js'), 'w') chunkdatenc = base64.b64encode(chunkdat).decode() outfl.write('$(document).ready(function() {\n') outfl.write(" GiLoad.load_run(null, '%s', 'base64');\n" % (chunkdatenc,)) outfl.write('});\n') outfl.close() alttexts = {} ls = blorbfile.chunkmap.get(b'RDes') if (ls): chunk = ls[0] alttexts = analyze_resourcedescs(chunk) outfl = open(os.path.join(outdirname, 'resourcemap.js'), 'w') outfl.write('/* resourcemap.js generated by blorbtool.py */\n') outfl.write('StaticImageInfo = {\n') usages = [ (num, chunk) for (use, num, chunk) in blorbfile.usages if (use == b'Pict') ] usages.sort() # on num first = True wholemap = collections.OrderedDict() for (num, chunk) in usages: try: (suffix, size) = analyze_pict(chunk) except Exception as ex: print('Error on Pict chunk %d: %s' % (num, ex)) continue picfilename = 'pict-%d.%s' % (num, suffix) map = collections.OrderedDict() map['image'] = num map['url'] = os.path.join(prefix, picfilename) if (b'Pict', num) in alttexts: map['alttext'] = alttexts.get( (b'Pict',num) ).decode('utf-8') map['width'] = size[0] map['height'] = size[1] wholemap['pict-%d' % (num,)] = map indexdat = json.dumps(map, indent=2) if (first): first = False else: outfl.write(',\n') outfl.write('%d: %s\n' % (num, indexdat)) outfl2 = open(os.path.join(outdirname, picfilename), 'wb') if (chunk.formtype and chunk.formtype != b'FORM'): outfl2.write(b'FORM') outfl2.write(struct.pack('>I', chunk.len)) outfl2.write(chunk.data()) outfl2.close() outfl.write('};\n') outfl.close() outfl = open(os.path.join(outdirname, 'resourcemap.json'), 'w') json.dump(wholemap, outfl, indent=2) outfl.write('\n') outfl.close() print('Wrote Quixe-compatible data to directory "%s".' % (outdirname,)) def cmd_delete(self, args): if (len(args) == 1): typ = self.parse_chunk_type(args[0], 'delete') ls = [ chunk for chunk in blorbfile.chunks if chunk.type == typ ] if (not ls): raise CommandError('No chunks of type %s' % (typestring(typ),)) elif (len(args) == 2): use = self.parse_chunk_type(args[0], 'delete') num = self.parse_int(args[1], 'delete (second argument)') chunk = blorbfile.usagemap.get( (use, num) ) if (not chunk): raise CommandError('No resource with usage %s, number %d' % (typestring(use), num)) ls = [ chunk ] else: raise CommandError('usage: delete TYPE | delete USE NUM') for chunk in ls: blorbfile.delete_chunk(chunk) print('Deleted %d chunk%s' % (len(ls), ('' if len(ls)==1 else 's'))) def cmd_reload(self, args): global blorbfile if (args): raise CommandError('usage: reload') filename = blorbfile.filename blorbfile.close() blorbfile = BlorbFile(filename) print('Reloaded %s.' % (filename,)) def cmd_save(self, args): global blorbfile if (len(args) == 0): outfilename = None elif (len(args) == 1): outfilename = args[0] else: raise CommandError('usage: save | save FILENAME') filename = blorbfile.save(outfilename) if (filename): # Reload, so that the blorbfile's Chunk (and its chunks) # refer to the new file. (The reloaded blorbfile will have # changed == False, too.) blorbfile.close() blorbfile = BlorbFile(filename) def cmd_dump(self, args): print('### chunks:', blorbfile.chunks) print('### chunkmap:', blorbfile.chunkmap) print('### chunkatpos:', blorbfile.chunkatpos) print('### usages:', blorbfile.usages) print('### usagemap:', blorbfile.usagemap) # Some utility functions. def typestring(dat): return "'" + dat.decode() + "'" def bytes_to_intarray(dat): if (bytes is str): # Python 2 return [ ord(val) for val in dat ] else: # Python 3 return [ val for val in dat ] def intarray_to_bytes(arr): if (bytes is str): # Python 2 return b''.join([ chr(val) for val in arr ]) else: # Python 3 return bytes(arr) def analyze_resourcedescs(chunk): res = {} dat = chunk.data() (subdat, dat) = (dat[:4], dat[4:]) count = struct.unpack('>I', subdat)[0] for ix in range(count): if (len(dat) < 12): break (subdat, dat) = (dat[:12], dat[12:]) subls = struct.unpack('>4c2I', subdat) usage = b''.join(subls[0:4]) strlen = subls[-1] num = subls[-2] if (len(dat) < strlen): break (subdat, dat) = (dat[:strlen], dat[strlen:]) res[(usage, num)] = subdat return res def analyze_pict(chunk): if (chunk.type == b'JPEG'): size = parse_jpeg(chunk.data()) return ('jpeg', size) if (chunk.type == b'PNG '): size = parse_png(chunk.data()) return ('png', size) raise Exception('Unrecognized Pict type: %s' % (chunk.type,)) def parse_png(dat): dat = bytes_to_intarray(dat) pos = 0 sig = dat[pos:pos+8] pos += 8 if sig != [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]: raise Exception('PNG signature does not match') while pos < len(dat): clen = (dat[pos] << 24) | (dat[pos+1] << 16) | (dat[pos+2] << 8) | dat[pos+3] pos += 4 ctyp = intarray_to_bytes(dat[pos:pos+4]) pos += 4 #print('Chunk:', ctyp, 'len', clen) if ctyp == b'IHDR': width = (dat[pos] << 24) | (dat[pos+1] << 16) | (dat[pos+2] << 8) | dat[pos+3] pos += 4 height = (dat[pos] << 24) | (dat[pos+1] << 16) | (dat[pos+2] << 8) | dat[pos+3] pos += 4 return (width, height) pos += clen pos += 4 raise Exception('No PNG header block found') def parse_jpeg(dat): dat = bytes_to_intarray(dat) #print('Length:', len(dat)) pos = 0 while pos < len(dat): if dat[pos] != 0xFF: raise Exception('marker is not FF') while dat[pos] == 0xFF: pos += 1 marker = dat[pos] pos += 1 if marker == 0x01 or (marker >= 0xD0 and marker <= 0xD9): #print('FF%02X*' % (marker,)) continue clen = (dat[pos] << 8) | dat[pos+1] #print('FF%02X, len %d' % (marker, clen)) if (marker >= 0xC0 and marker <= 0xCF and marker != 0xC8): if clen <= 7: raise Exception('SOF block is too small') bits = dat[pos+2] height = (dat[pos+3] << 8) | dat[pos+4] width = (dat[pos+5] << 8) | dat[pos+6] return (width, height) pos += clen raise Exception('SOF block not found') # Actual work begins here. if (opts.listcommands): BlorbTool.show_commands() sys.exit(-1) if (not args and not opts.newfile): popt.print_help() sys.exit(-1) filename = None if (args): filename = args.pop(0) if (opts.newfile and not opts.output): opts.output = filename filename = None try: blorbfile = BlorbFile(filename, opts.output) except Exception as ex: print(ex.__class__.__name__+':', str(ex)) if (opts.verbose): raise sys.exit(-1) # If args exist, execute them as a command. If not, loop grabbing and # executing commands until we discover that the user has executed Quit. # (The handler catches all exceptions except KeyboardInterrupt.) try: tool = BlorbTool() if (args): tool.set_interactive(False) tool.handle(args) blorbfile.sanity_check() blorbfile.save_if_needed() else: tool.set_interactive(True) while (not tool.quit_yet()): tool.handle() blorbfile.sanity_check() blorbfile.save_if_needed() print('') except KeyboardInterrupt: print('') except EOFError: print('') blorbfile.close()