#!/usr/bin/env python

# blorbtool.py: A (semi-)multifunctional Blorb utility
# Created by Andrew Plotkin ([email protected])
# Last updated: June 12, 2017
# 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

from chunk import Chunk
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 '<BlorbChunk %s at %d, len %d>' % (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 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 ord(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('<exiting>')
except KeyboardInterrupt:
   print('<interrupted>')
except EOFError:
   print('<eof>')

blorbfile.close()