Skip to content

Instantly share code, notes, and snippets.

@foone
Created May 3, 2023 00:23
Show Gist options
  • Star 1 You must be signed in to star a gist
  • Fork 1 You must be signed in to fork a gist
  • Save foone/ffc3c0625c12246d1e774989a75f298c to your computer and use it in GitHub Desktop.
Save foone/ffc3c0625c12246d1e774989a75f298c to your computer and use it in GitHub Desktop.
import struct, os, sys,array, glob, itertools, argparse
from PIL import Image
A8SIGNATURE =0xA0A1A2A3
COMPRESS_NONE = 0
COMPRESS_RLE = 1
COMPRESSION_FORMATS=(COMPRESS_NONE, COMPRESS_RLE)
ESCAPE_CODE = 0
ESC_ENDBITMAP = 1
ESC_DELTA = 2
ESC_RANDOM = 3
PALETTE_LENGTH = 256*4
def translate_through_palette(data, pal):
out=array.array('B')
for c in data:
out.extend(pal[ord(c)])
return out
def unRLE8(src):
out = internalRLE8(src)
return ''.join(out)
def internalRLE8(src):
def readat(o, pattern):
size = struct.calcsize(pattern)
return struct.unpack('<'+pattern, src[o:o+size])
out=[]
offset=0
while True:
run,esc = readat(offset,'BB')
offset+=2
if run==ESCAPE_CODE:
if esc == ESC_ENDBITMAP:
return out
elif esc == ESC_DELTA:
(delta_length,) = readat(offset, 'H')
offset+=2
out.append('\0'*delta_length)
elif esc >= ESC_RANDOM:
direct_copy_length = (esc - (ESC_RANDOM-1))
out.append(src[offset:offset+direct_copy_length])
offset+=direct_copy_length
else:
out.append(chr(esc)*run)
def read_struct(f, pattern):
bytes_to_read = struct.calcsize(pattern)
loaded_buffer = f.read(bytes_to_read)
if len(loaded_buffer) != bytes_to_read:
#TODO: provide more context?
raise IOError('Hit EOF trying to read {} bytes!'.format(bytes_to_read))
return struct.unpack('<'+pattern, loaded_buffer)
# This is not really useful but is nice for symmetry
def write_stuct(f, pattern, *args):
f.write(struct.pack(pattern, *args))
def load_a8(f):
(signature,)=read_struct(f,'L')
if signature != A8SIGNATURE:
raise IOError('Invalid A8 header. Expected {:04x}, got {:04x}'.format(A8SIGNATURE, signature))
w,h,bpp,compression,compressed_size = read_struct(f,'LLLLL')
if bpp != 8:
raise IOError('A8 file has {} bits per pixel, only 8 is supported!'.format(bpp))
if compression not in COMPRESSION_FORMATS:
raise IOError('A8 file has {} compression, only NONE or RLE is supported!'.format(compression))
# The code ignores the compressed_size, so we will too.
# palette is 256 RGBQUADS, so BGRA order.
bgra = read_struct(f,'{}B'.format(256*4))
rgba_palette = [(r,g,b,a) for (b,g,r,a) in [bgra[i:i+4] for i in range(0, len(bgra), 4)]]
size = w*h
src = f.read(size)
decompressed_src = unRLE8(src)
rawdata = translate_through_palette(decompressed_src, rgba_palette)
im=Image.frombytes('RGBA',(w,h), rawdata)
return im.transpose(Image.FLIP_TOP_BOTTOM)
def convert_to_paletted(im):
color_iterator = itertools.count()
palettes={}
def lookup_color(rgba):
r,g,b,a = rgba
palkey = (b,g,r,a)
try:
return palettes[palkey]
except KeyError:
index = palettes[palkey]=next(color_iterator)
return index
px = im.load()
w,h= im.size
outdata=array.array('B')
for y in range(h-1, -1, -1): # THe image is encoded bottom to top, like BMP
for x in range(w):
i=lookup_color(px[x,y])
outdata.append(i)
ordered_palette = [(index, key) for (key, index) in palettes.items()]
ordered_palette.sort()
palette_array = array.array('B')
for _,k in ordered_palette:
palette_array.extend(k)
if len(palette_array) > PALETTE_LENGTH:
raise ValueError('Image has too many colors! {} palette entries.'.format(len(palette_array)//4))
elif len(palette_array) < PALETTE_LENGTH:
padding = PALETTE_LENGTH - len(palette_array)
palette_array.extend([0]*padding)
return palette_array, outdata
def save_to_a8(im, f):
w,h = im.size
write_stuct(f, 'LLLLLL', A8SIGNATURE, w, h, 8, 0, 0)
palette_array, image_data = convert_to_paletted(im.convert('RGBA')) # to support RGB formats
palette_array.tofile(f)
image_data.tofile(f)
def convert_to_png(a8_filename, png_filename):
with open(a8_filename,'rb') as f:
im=load_a8(f)
im.save(png_filename)
def convert_to_a8(png_filename, a8_filename):
im = Image.open(png_filename)
with open(a8_filename, 'wb') as f:
save_to_a8(im, f)
def new_ext(filename, ext):
base, _ = os.path.splitext(filename)
return '{}.{}'.format(base, ext)
if __name__=='__main__':
parser = argparse.ArgumentParser(description='Convert files to/from the Microsoft A8 format')
action_group = parser.add_mutually_exclusive_group(required=True)
action_group.add_argument('--to-png', action='store_true', help='Convert files to PNG')
action_group.add_argument('--to-a8', action='store_true', help='Convert files to A8')
parser.add_argument('files', metavar='FILE', type=str, nargs='*', help='files to convert')
parser.add_argument('--all', '-a', action='store_true', help='Convert all applicable files in the local directory')
args = parser.parse_args()
orig_ext, target_ext = ('a8','png') if args.to_png else ('png','a8')
convert_function = convert_to_png if args.to_png else convert_to_a8
if args.all:
print 'Converting all .{} files in current directory'.format(orig_ext)
args.files = glob.glob('*.{}'.format(orig_ext))
for basefile in args.files:
print 'Converting', basefile
convert_function(basefile, new_ext(basefile, target_ext))
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment