#!/usr/bin/env python
# blorbtool.py: A (semi-)multifunctional Blorb utility
# Created by Andrew Plotkin (
[email protected])
# 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 '<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 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('<exiting>')
except KeyboardInterrupt:
print('<interrupted>')
except EOFError:
print('<eof>')
blorbfile.close()