-
-
Save vmedea/38a227f34863bec16b7546992b5b8534 to your computer and use it in GitHub Desktop.
#!/usr/bin/env python3 | |
# (C) Mara Huldra 2022 | |
# SPDX-License-Identifier: MIT | |
''' | |
Parser for XBin file (ANSI/ASCII/PETSCII art grid). | |
Convert to unicode and print it out. | |
''' | |
import itertools | |
import struct | |
import sys | |
class Flags: | |
''' | |
XBin header flags. | |
''' | |
PALETTE = 1 | |
FONT = 2 | |
COMPRESS = 4 | |
NONBLINK = 8 | |
CHARS512 = 16 | |
class XBin: | |
def __init__(self, width, height, flags, pal, font, fontsize, data): | |
self.width = width | |
self.height = height | |
self.flags = flags | |
self.pal = pal | |
self.font = font | |
self.fontsize = fontsize | |
self.data = data | |
def sample(self, x, y): | |
idx = (self.width * y + x) * 2 | |
(glyph, attr) = self.data[idx:idx+2] | |
return (glyph, attr) | |
def rgb(self, i): | |
r = self.pal[i*3+0] | |
g = self.pal[i*3+1] | |
b = self.pal[i*3+2] | |
return ((r<<2) | (r>>4), (g<<2) | (g>>4), (b<<2) | (b>>4)) | |
@classmethod | |
def parse(cls, f): | |
hdr = f.read(11) | |
(fid, width, height, fontsize, flags) = struct.unpack('<5sHHbb', hdr) | |
if fid != b'XBIN\x1a': | |
raise Exception('Could not parse XBIN header: invalid magic') | |
num_chars = 256 | |
if flags & Flags.CHARS512: | |
num_chars = 512 | |
if flags & Flags.PALETTE: | |
pal = f.read(48) | |
else: | |
pal = None | |
if flags & Flags.FONT: | |
font = [] | |
for _ in range(num_chars): | |
glyph = f.read(fontsize) | |
if len(glyph) != fontsize: | |
raise Exception('XBIN font is truncated') | |
font.append(glyph) | |
else: | |
font = None | |
if flags & Flags.COMPRESS: | |
raise Exception('Compressed XBIN is not supported') | |
data = f.read(width * height * 2) | |
if len(data) != width * height * 2: | |
raise Exception('XBIN data ends early') | |
return cls(width, height, flags, pal, font, fontsize, data) | |
# mapping PETSCII to unicode | |
petscii_mapping = ( | |
'@' # 0x40 -> COMMERCIAL AT | |
'A' # 0x41 -> LATIN CAPITAL LETTER A | |
'B' # 0x42 -> LATIN CAPITAL LETTER B | |
'C' # 0x43 -> LATIN CAPITAL LETTER C | |
'D' # 0x44 -> LATIN CAPITAL LETTER D | |
'E' # 0x45 -> LATIN CAPITAL LETTER E | |
'F' # 0x46 -> LATIN CAPITAL LETTER F | |
'G' # 0x47 -> LATIN CAPITAL LETTER G | |
'H' # 0x48 -> LATIN CAPITAL LETTER H | |
'I' # 0x49 -> LATIN CAPITAL LETTER I | |
'J' # 0x4A -> LATIN CAPITAL LETTER J | |
'K' # 0x4B -> LATIN CAPITAL LETTER K | |
'L' # 0x4C -> LATIN CAPITAL LETTER L | |
'M' # 0x4D -> LATIN CAPITAL LETTER M | |
'N' # 0x4E -> LATIN CAPITAL LETTER N | |
'O' # 0x4F -> LATIN CAPITAL LETTER O | |
'P' # 0x50 -> LATIN CAPITAL LETTER P | |
'Q' # 0x51 -> LATIN CAPITAL LETTER Q | |
'R' # 0x52 -> LATIN CAPITAL LETTER R | |
'S' # 0x53 -> LATIN CAPITAL LETTER S | |
'T' # 0x54 -> LATIN CAPITAL LETTER T | |
'U' # 0x55 -> LATIN CAPITAL LETTER U | |
'V' # 0x56 -> LATIN CAPITAL LETTER V | |
'W' # 0x57 -> LATIN CAPITAL LETTER W | |
'X' # 0x58 -> LATIN CAPITAL LETTER X | |
'Y' # 0x59 -> LATIN CAPITAL LETTER Y | |
'Z' # 0x5A -> LATIN CAPITAL LETTER Z | |
'[' # 0x5B -> LEFT SQUARE BRACKET | |
'\xa3' # 0x5C -> POUND SIGN | |
']' # 0x5D -> RIGHT SQUARE BRACKET | |
'\u2191' # 0x5E -> UPWARDS ARROW | |
'\u2190' # 0x5F -> LEFTWARDS ARROW | |
' ' # 0x20 -> SPACE | |
'!' # 0x21 -> EXCLAMATION MARK | |
'"' # 0x22 -> QUOTATION MARK | |
'#' # 0x23 -> NUMBER SIGN | |
'$' # 0x24 -> DOLLAR SIGN | |
'%' # 0x25 -> PERCENT SIGN | |
'&' # 0x26 -> AMPERSAND | |
"'" # 0x27 -> APOSTROPHE | |
'(' # 0x28 -> LEFT PARENTHESIS | |
')' # 0x29 -> RIGHT PARENTHESIS | |
'*' # 0x2A -> ASTERISK | |
'+' # 0x2B -> PLUS SIGN | |
',' # 0x2C -> COMMA | |
'-' # 0x2D -> HYPHEN-MINUS | |
'.' # 0x2E -> FULL STOP | |
'/' # 0x2F -> SOLIDUS | |
'0' # 0x30 -> DIGIT ZERO | |
'1' # 0x31 -> DIGIT ONE | |
'2' # 0x32 -> DIGIT TWO | |
'3' # 0x33 -> DIGIT THREE | |
'4' # 0x34 -> DIGIT FOUR | |
'5' # 0x35 -> DIGIT FIVE | |
'6' # 0x36 -> DIGIT SIX | |
'7' # 0x37 -> DIGIT SEVEN | |
'8' # 0x38 -> DIGIT EIGHT | |
'9' # 0x39 -> DIGIT NINE | |
':' # 0x3A -> COLON | |
';' # 0x3B -> SEMICOLON | |
'<' # 0x3C -> LESS-THAN SIGN | |
'=' # 0x3D -> EQUALS SIGN | |
'>' # 0x3E -> GREATER-THAN SIGN | |
'?' # 0x3F -> QUESTION MARK | |
'\U0001fb79'# 0x60 -> HORIZONTAL ONE EIGHTH BLOCK-5 | |
'\u2660' # 0x61 -> BLACK SPADE SUIT | |
'\U0001fb72'# 0x62 -> VERTICAL ONE EIGHTH BLOCK-4 | |
'\U0001fb78'# 0x63 -> HORIZONTAL ONE EIGHTH BLOCK-4 | |
'\U0001fb77'# 0x64 -> BOX DRAWINGS LIGHT HORIZONTAL ONE QUARTER UP | |
'\U0001fb76'# 0x65 -> HORIZONTAL ONE EIGHTH BLOCK-2 | |
'\U0001fb7a'# 0x66 -> HORIZONTAL ONE EIGHTH BLOCK-6 | |
'\U0001fb71'# 0x67 -> VERTICAL ONE EIGHTH BLOCK-3 | |
'\U0001fb74'# 0x68 -> VERTICAL ONE EIGHTH BLOCK-6 | |
'\u256e' # 0x69 -> BOX DRAWINGS LIGHT ARC DOWN AND LEFT | |
'\u2570' # 0x6A -> BOX DRAWINGS LIGHT ARC UP AND RIGHT | |
'\u256f' # 0x6B -> BOX DRAWINGS LIGHT ARC UP AND LEFT | |
'\U0001fb7c'# 0x6C -> LEFT AND LOWER ONE EIGHTH BLOCK | |
'\u2572' # 0x6D -> BOX DRAWINGS LIGHT DIAGONAL UPPER LEFT TO LOWER RIGHT | |
'\u2571' # 0x6E -> BOX DRAWINGS LIGHT DIAGONAL UPPER RIGHT TO LOWER LEFT | |
'\U0001fb7d'# 0x6F -> LEFT AND UPPER ONE EIGHTH BLOCK | |
'\U0001fb7e'# 0x70 -> RIGHT AND UPPER ONE EIGHTH BLOCK | |
'\u25cf' # 0x71 -> BLACK CIRCLE | |
'\U0001fb7b'# 0x72 -> HORIZONTAL ONE EIGHTH BLOCK-7 | |
'\u2665' # 0x73 -> BLACK HEART SUIT | |
'\U0001fb70'# 0x74 -> VERTICAL ONE EIGHTH BLOCK-6 | |
'\u256d' # 0x75 -> BOX DRAWINGS LIGHT ARC DOWN AND RIGHT | |
'\u2573' # 0x76 -> BOX DRAWINGS LIGHT DIAGONAL CROSS | |
'\u25cb' # 0x77 -> WHITE CIRCLE | |
'\u2663' # 0x78 -> BLACK CLUB SUIT | |
'\U0001fb75'# 0x79 -> VERTICAL ONE EIGHTH BLOCK-7 | |
'\u2666' # 0x7A -> BLACK DIAMOND SUIT | |
'\u253c' # 0x7B -> BOX DRAWINGS LIGHT VERTICAL AND HORIZONTAL | |
'\U0001fb8c'# 0x7C -> LEFT HALF MEDIUM SHADE | |
'\u2502' # 0x7D -> BOX DRAWINGS LIGHT VERTICAL | |
'\U0001fb96'# 0x7E -> INVERSE CHECKER BOARD FILL | |
'\U0001fb98'# 0x7F -> UPPER LEFT TO LOWER RIGHT FILL | |
'\xa0' # 0xA0 -> NO-BREAK SPACE | |
'\u258c' # 0xA1 -> LEFT HALF BLOCK | |
'\u2584' # 0xA2 -> LOWER HALF BLOCK | |
'\u2594' # 0xA3 -> UPPER ONE EIGHTH BLOCK | |
'\u2581' # 0xA4 -> LOWER ONE EIGHTH BLOCK | |
'\u258f' # 0xA5 -> LEFT ONE EIGHTH BLOCK | |
'\u2592' # 0xA6 -> MEDIUM SHADE | |
'\u2595' # 0xA7 -> RIGHT ONE EIGHTH BLOCK | |
'\U0001fb8f'# 0xA8 -> LOWER HALF MEDIUM SHADE | |
'\u25e4' # 0xA9 -> BLACK UPPER LEFT TRIANGLE | |
'\U0001fb87'# 0xAA -> RIGHT ONE QUARTER BLOCK | |
'\u251c' # 0xAB -> BOX DRAWINGS LIGHT VERTICAL AND RIGHT | |
'\u2597' # 0xAC -> QUADRANT LOWER RIGHT | |
'\u2514' # 0xAD -> BOX DRAWINGS LIGHT UP AND RIGHT | |
'\u2510' # 0xAE -> BOX DRAWINGS LIGHT DOWN AND LEFT | |
'\u2582' # 0xAF -> LOWER ONE QUARTER BLOCK | |
'\u250c' # 0xB0 -> BOX DRAWINGS LIGHT DOWN AND RIGHT | |
'\u2534' # 0xB1 -> BOX DRAWINGS LIGHT UP AND HORIZONTAL | |
'\u252c' # 0xB2 -> BOX DRAWINGS LIGHT DOWN AND HORIZONTAL | |
'\u2524' # 0xB3 -> BOX DRAWINGS LIGHT VERTICAL AND LEFT | |
'\u258e' # 0xB4 -> LEFT ONE QUARTER BLOCK | |
'\u258d' # 0xB5 -> LEFT THREE EIGTHS BLOCK | |
'\U0001fb88'# 0xB6 -> RIGHT THREE EIGHTHS BLOCK | |
'\U0001fb82'# 0xB7 -> UPPER ONE QUARTER BLOCK | |
'\U0001fb83'# 0xB8 -> UPPER THREE EIGHTHS BLOCK | |
'\u2583' # 0xB9 -> LOWER THREE EIGHTHS BLOCK | |
'\U0001fb7f'# 0xBA -> RIGHT AND LOWER ONE EIGHTH BLOCK | |
'\u2596' # 0xBB -> QUADRANT LOWER LEFT | |
'\u259d' # 0xBC -> QUADRANT UPPER RIGHT | |
'\u2518' # 0xBD -> BOX DRAWINGS LIGHT UP AND LEFT | |
'\u2598' # 0xBE -> QUADRANT UPPER LEFT | |
'\u259a' # 0xBF -> QUADRANT UPPER LEFT AND LOWER RIGHT | |
) | |
# cp437 mapping from rexpaint | |
cp437_mapping = ( | |
'\u00A0', # 0 (nothing really, but using hard space) | |
'\u263A', # 1 | |
'\u263B', # 2 | |
'\u2665', # 3 | |
'\u2666', # 4 | |
'\u2663', # 5 | |
'\u2660', # 6 | |
'\u2022', # 7 | |
'\u25DB', # 8 | |
'\u25CB', # 9 | |
'\u25D9', # 10 | |
'\u2642', # 11 | |
'\u2640', # 12 | |
'\u266A', # 13 | |
'\u266B', # 14 | |
'\u263C', # 15 | |
'\u25BA', # 16 | |
'\u25C4', # 17 | |
'\u2195', # 18 | |
'\u203C', # 19 | |
'\u00B6', # 20 | |
'\u00A7', # 21 | |
'\u25AC', # 22 | |
'\u21A8', # 23 | |
'\u2191', # 24 | |
'\u2193', # 25 | |
'\u2192', # 26 | |
'\u2190', # 27 | |
'\u221F', # 28 | |
'\u2194', # 29 | |
'\u25B2', # 30 | |
'\u25BC', # 31 | |
'\u00A0', # 32 (must used hard space instead of actual space, to prevent collapsing) | |
'!', # 33 | |
'\"', # 34 | |
'#', # 35 | |
'$', # 36 | |
'%', # 37 | |
'&', # 38 | |
'\'', # 39 | |
'(', # 40 | |
')', # 41 | |
'*', # 42 | |
'+', # 43 | |
',', # 44 | |
'-', # 45 | |
'.', # 46 | |
'/', # 47 | |
'0', # 48 | |
'1', # 49 | |
'2', # 50 | |
'3', # 51 | |
'4', # 52 | |
'5', # 53 | |
'6', # 54 | |
'7', # 55 | |
'8', # 56 | |
'9', # 57 | |
':', # 58 | |
';', # 59 | |
'<', # 60 | |
'=', # 61 | |
'>', # 62 | |
'?', # 63 | |
'@', # 64 | |
'A', # 65 | |
'B', # 66 | |
'C', # 67 | |
'D', # 68 | |
'E', # 69 | |
'F', # 70 | |
'G', # 71 | |
'H', # 72 | |
'I', # 73 | |
'J', # 74 | |
'K', # 75 | |
'L', # 76 | |
'M', # 77 | |
'N', # 78 | |
'O', # 79 | |
'P', # 80 | |
'Q', # 81 | |
'R', # 82 | |
'S', # 83 | |
'T', # 84 | |
'U', # 85 | |
'V', # 86 | |
'W', # 87 | |
'X', # 88 | |
'Y', # 89 | |
'Z', # 90 | |
'[', # 91 | |
'\\', # 92 | |
']', # 93 | |
'^', # 94 | |
'_', # 95 | |
'`', # 96 | |
'a', # 97 | |
'b', # 98 | |
'c', # 99 | |
'd', # 100 | |
'e', # 101 | |
'f', # 102 | |
'g', # 103 | |
'h', # 104 | |
'i', # 105 | |
'j', # 106 | |
'k', # 107 | |
'l', # 108 | |
'm', # 109 | |
'n', # 110 | |
'o', # 111 | |
'p', # 112 | |
'q', # 113 | |
'r', # 114 | |
's', # 115 | |
't', # 116 | |
'u', # 117 | |
'v', # 118 | |
'w', # 119 | |
'x', # 120 | |
'y', # 121 | |
'z', # 122 | |
'{', # 123 | |
'|', # 124 | |
'}', # 125 | |
'~', # 126 | |
'\u2302', # 127 | |
'\u00C7', # 128 | |
'\u00FC', # 129 | |
'\u00E9', # 130 | |
'\u00E2', # 131 | |
'\u00E4', # 132 | |
'\u00E0', # 133 | |
'\u00E5', # 134 | |
'\u00E7', # 135 | |
'\u00EA', # 136 | |
'\u00EB', # 137 | |
'\u00E8', # 138 | |
'\u00EF', # 139 | |
'\u00EE', # 140 | |
'\u00EC', # 141 | |
'\u00C4', # 142 | |
'\u00C5', # 143 | |
'\u00C9', # 144 | |
'\u00E6', # 145 | |
'\u00C6', # 146 | |
'\u00F4', # 147 | |
'\u00F6', # 148 | |
'\u00F2', # 149 | |
'\u00FB', # 150 | |
'\u00F9', # 151 | |
'\u00FF', # 152 | |
'\u00D6', # 153 | |
'\u00DC', # 154 | |
'\u00A2', # 155 | |
'\u00A3', # 156 | |
'\u00A5', # 157 | |
'\u20A7', # 158 | |
'\u0192', # 159 | |
'\u00E1', # 160 | |
'\u00ED', # 161 | |
'\u00F3', # 162 | |
'\u00FA', # 163 | |
'\u00F1', # 164 | |
'\u00D1', # 165 | |
'\u00AA', # 166 | |
'\u00BA', # 167 | |
'\u00BF', # 168 | |
'\u2310', # 169 | |
'\u00AC', # 170 | |
'\u00BD', # 171 | |
'\u00BC', # 172 | |
'\u00A1', # 173 | |
'\u00AB', # 174 | |
'\u00BB', # 175 | |
'\u2591', # 176 | |
'\u2592', # 177 | |
'\u2593', # 178 | |
'\u2502', # 179 | |
'\u2524', # 180 | |
'\u2561', # 181 | |
'\u2562', # 182 | |
'\u2556', # 183 | |
'\u2555', # 184 | |
'\u2563', # 185 | |
'\u2551', # 186 | |
'\u2557', # 187 | |
'\u255D', # 188 | |
'\u255C', # 189 | |
'\u255B', # 190 | |
'\u2510', # 191 | |
'\u2514', # 192 | |
'\u2534', # 193 | |
'\u252C', # 194 | |
'\u251C', # 195 | |
'\u2500', # 196 | |
'\u253C', # 197 | |
'\u255E', # 198 | |
'\u255F', # 199 | |
'\u255A', # 200 | |
'\u2554', # 201 | |
'\u2569', # 202 | |
'\u2566', # 203 | |
'\u2560', # 204 | |
'\u2550', # 205 | |
'\u256C', # 206 | |
'\u2567', # 207 | |
'\u2568', # 208 | |
'\u2564', # 209 | |
'\u2565', # 210 | |
'\u2559', # 211 | |
'\u2558', # 212 | |
'\u2552', # 213 | |
'\u2553', # 214 | |
'\u256B', # 215 | |
'\u256A', # 216 | |
'\u2518', # 217 | |
'\u250C', # 218 | |
'\u2588', # 219 | |
'\u2584', # 220 | |
'\u258C', # 221 | |
'\u2590', # 222 | |
'\u2580', # 223 | |
'\u03B1', # 224 | |
'\u00DF', # 225 | |
'\u0393', # 226 | |
'\u03C0', # 227 | |
'\u03A3', # 228 | |
'\u03C3', # 229 | |
'\u00B5', # 230 | |
'\u03C4', # 231 | |
'\u03A6', # 232 | |
'\u0398', # 233 | |
'\u03A9', # 234 | |
'\u03B4', # 235 | |
'\u221E', # 236 | |
'\u03C6', # 237 | |
'\u03B5', # 238 | |
'\u2229', # 239 | |
'\u2261', # 240 | |
'\u00B1', # 241 | |
'\u2265', # 242 | |
'\u2264', # 243 | |
'\u2320', # 244 | |
'\u2321', # 245 | |
'\u00F7', # 246 | |
'\u2248', # 247 | |
'\u00B0', # 248 | |
'\u2219', # 249 | |
'\u00B7', # 250 | |
'\u221A', # 251 | |
'\u207F', # 252 | |
'\u00B2', # 253 | |
'\u25A0', # 254 | |
'\u25A1', # 255 (changed this to be empty version of 254) | |
) | |
RESET = '\x1b[0m' | |
class LineBuilder: | |
''' | |
Build a line of text with terminal attributes. | |
Always puts a RESET token at the beginning and end of the line to make | |
sure the terminal is in consistent state in-between. | |
''' | |
def __init__(self): | |
self.items = [RESET] | |
def update_state(self, update): | |
codes = itertools.chain.from_iterable(update.values()) | |
self.emit_sgr(list(codes)) | |
def emit_sgr(self, codes): | |
if codes: | |
# emit SGR sequence | |
self.items.append('\x1b[') | |
self.items.append(';'.join((str(code) for code in codes))) | |
self.items.append('m') | |
def append(self, text): | |
self.items.append(text) | |
def __str__(self): | |
return ''.join(self.items) + RESET | |
class OptimizingLineBuilder(LineBuilder): | |
''' | |
Build a line of text with terminal attributes. Keep track of current | |
terminal state to emit escape codes on changes only. | |
''' | |
def __init__(self): | |
super().__init__() | |
# Default terminal state after reset: | |
# fg is default color, bg is default color | |
self.state = {'fg': (39,), 'bg': (49,)} | |
self.state_updates = {} | |
def update_state(self, update): | |
self.state_updates.update(update) | |
def flush_state(self): | |
codes = (v for k,v in self.state_updates.items() if v != self.state[k]) | |
codes = list(itertools.chain.from_iterable(codes)) | |
self.emit_sgr(codes) | |
self.state.update(self.state_updates) | |
self.state_updates = {} | |
def append(self, text): | |
self.flush_state() | |
self.items.append(text) | |
class AttrMap: | |
'''Attribute mapping.''' | |
@staticmethod | |
def true(pal, attr, glyph_attr): | |
''' | |
Set attribute using true-color ANSI sequence. | |
''' | |
fg = pal.rgb(attr & 15) | |
bg = pal.rgb(attr >> 4) | |
if glyph_attr & 0x01: # reverse-video bit | |
(fg, bg) = (bg, fg) | |
return {'fg': (38, 2, fg[0], fg[1], fg[2]), | |
'bg': (48, 2, bg[0], bg[1], bg[2])} | |
@staticmethod | |
def cga16(lpal, attr, glyph_attr): | |
''' | |
Set attribute using 16-color terminal palette, assuming | |
the xbin file uses default CGA16 palette. | |
''' | |
def swizzle(attr): | |
return (attr&1)*4 + ((attr&2)>>1)*2 + ((attr&4)>>2)*1 + (attr >> 3) * 60 | |
if glyph_attr & 0x01: # reverse-video bit | |
attr = (attr >> 4) | ((attr << 4) & 0xf0) | |
return {'fg': (30 + swizzle(attr & 15),), | |
'bg': (40 + swizzle(attr >> 4),)} | |
class GlyphMap: | |
'''Glyph mapping.''' | |
def cp437(glyph): | |
''' | |
General unicode approximation of DOS cp437. | |
''' | |
return cp437_mapping[glyph], 0 | |
def petscii(glyph): | |
''' | |
General unicode approximation of PETSCII. | |
''' | |
return petscii_mapping[glyph & 0x7f], (glyph >> 7) & 1 | |
def c64_pro_mono(glyph): | |
''' | |
Use PETSCII glyphs in private use area for the "C64 Pro Mono" font. | |
''' | |
return chr(0xee00 + glyph), 0 | |
def dump(xb, attr_map, glyph_map, line_builder=OptimizingLineBuilder): | |
for y in range(xb.height): | |
line = line_builder() | |
for x in range(xb.width): | |
(glyph, attr) = xb.sample(x, y) | |
(ch, glyph_attr) = glyph_map(glyph) | |
line.update_state(attr_map(xb, attr, glyph_attr)) | |
line.append(ch) | |
print(str(line)) | |
def dump_mult(images, width, attr_map, glyph_map, line_builder=OptimizingLineBuilder): | |
for y in range(images[0].height): | |
line = line_builder() | |
for xb in images: | |
for x in range(width): | |
(glyph, attr) = xb.sample(x, y) | |
(ch, glyph_attr) = glyph_map(glyph) | |
line.update_state(attr_map(xb, attr, glyph_attr)) | |
line.append(ch) | |
print(str(line)) | |
def dump_font(xb): | |
for ch in range(256): | |
print(f'{ch:02x}') | |
for y in range(xb.fontsize): | |
line = [] | |
for x in range(8): | |
if xb.font[ch][y] & (1 << (7-x)): | |
line.append('█') | |
else: | |
line.append(' ') | |
print(''.join(line)) | |
def parse_args(): | |
import argparse | |
parser = argparse.ArgumentParser() | |
parser.add_argument("filename", help="xbin file to display") | |
parser.add_argument("--optimize", "-O", default="1", choices=["0", "1"], help="Try to optimize output (0 emits full SGR state for every character, 1 only for changes and between lines, default is 1).") | |
parser.add_argument("--print-font", "-f", help="Print font.", action="store_true") | |
parser.add_argument("--attr-mapping", "-a", | |
default="true", | |
help='Which attribute mapping to use (one of "true", "cga16", default: true).') | |
parser.add_argument("--glyph-mapping", "-m", | |
default="petscii", | |
help='Which glyph mapping to use (one of "cp437", "petscii", "petscii_c64_pro_mono" or "custom:<file.json>", default: petscii).') | |
return parser.parse_args() | |
def main(): | |
import json, sys | |
args = parse_args() | |
try: | |
attr_map = getattr(AttrMap, args.attr_mapping) | |
except AttributeError: | |
print(f'Error: unknown attribute mapping {args.attr_mapping}.', file=sys.stderr) | |
exit(1) | |
if args.glyph_mapping.startswith('custom:'): | |
with open(args.glyph_mapping[7:], 'r') as f: | |
mapping = json.load(f) | |
glyph_map = mapping.__getitem__ | |
else: | |
try: | |
glyph_map = getattr(GlyphMap, args.glyph_mapping) | |
except AttributeError: | |
print(f'Error: unknown glyph mapping {args.glyph_mapping}.', file=sys.stderr) | |
exit(1) | |
line_builder = [LineBuilder, OptimizingLineBuilder][int(args.optimize)] | |
with open(args.filename, 'rb') as f: | |
xb = XBin.parse(f) | |
if args.print_font: | |
dump_font(xb) | |
else: | |
dump(xb, attr_map, glyph_map, line_builder) | |
if __name__ == '__main__': | |
main() |
#!/usr/bin/env python3 | |
# (C) Mara Huldra 2022 | |
# SPDX-License-Identifier: MIT | |
''' | |
Command-line tarot oracle. | |
Cards by littlebitspace (https://16colo.rs/pack/lbs-tarot/). | |
''' | |
from parse_xbin import XBin, dump, dump_mult, AttrMap, GlyphMap | |
import os | |
from pathlib import Path | |
import random | |
import sys | |
CARDS = [ | |
'littlebitspace-0major_arcana00.xb', | |
'littlebitspace-0major_arcana01.xb', | |
'littlebitspace-0major_arcana02.xb', | |
'littlebitspace-0major_arcana03.xb', | |
'littlebitspace-0major_arcana04.xb', | |
'littlebitspace-0major_arcana05.xb', | |
'littlebitspace-0major_arcana06.xb', | |
'littlebitspace-0major_arcana07.xb', | |
'littlebitspace-0major_arcana08.xb', | |
'littlebitspace-0major_arcana09.xb', | |
'littlebitspace-0major_arcana10.xb', | |
'littlebitspace-0major_arcana11.xb', | |
'littlebitspace-0major_arcana12.xb', | |
'littlebitspace-0major_arcana13.xb', | |
'littlebitspace-0major_arcana14.xb', | |
'littlebitspace-0major_arcana15.xb', | |
'littlebitspace-0major_arcana16.xb', | |
'littlebitspace-0major_arcana17.xb', | |
'littlebitspace-0major_arcana18.xb', | |
'littlebitspace-0major_arcana19.xb', | |
'littlebitspace-0major_arcana20.xb', | |
'littlebitspace-0major_arcana21.xb', | |
'littlebitspace-1wands01.xb', | |
'littlebitspace-1wands02.xb', | |
'littlebitspace-1wands03.xb', | |
'littlebitspace-1wands04.xb', | |
'littlebitspace-1wands05.xb', | |
'littlebitspace-1wands06.xb', | |
'littlebitspace-1wands07.xb', | |
'littlebitspace-1wands08.xb', | |
'littlebitspace-1wands09.xb', | |
'littlebitspace-1wands10.xb', | |
'littlebitspace-1wands11.xb', | |
'littlebitspace-1wands12.xb', | |
'littlebitspace-1wands13.xb', | |
'littlebitspace-1wands14.xb', | |
'littlebitspace-2cups01.xb', | |
'littlebitspace-2cups02.xb', | |
'littlebitspace-2cups03.xb', | |
'littlebitspace-2cups04.xb', | |
'littlebitspace-2cups05.xb', | |
'littlebitspace-2cups06.xb', | |
'littlebitspace-2cups07.xb', | |
'littlebitspace-2cups08.xb', | |
'littlebitspace-2cups09.xb', | |
'littlebitspace-2cups10.xb', | |
'littlebitspace-2cups11.xb', | |
'littlebitspace-2cups12.xb', | |
'littlebitspace-2cups13.xb', | |
'littlebitspace-2cups14.xb', | |
'littlebitspace-3swords01.xb', | |
'littlebitspace-3swords02.xb', | |
'littlebitspace-3swords03.xb', | |
'littlebitspace-3swords04.xb', | |
'littlebitspace-3swords05.xb', | |
'littlebitspace-3swords06.xb', | |
'littlebitspace-3swords07.xb', | |
'littlebitspace-3swords08.xb', | |
'littlebitspace-3swords09.xb', | |
'littlebitspace-3swords10.xb', | |
'littlebitspace-3swords11.xb', | |
'littlebitspace-3swords12.xb', | |
'littlebitspace-3swords13.xb', | |
'littlebitspace-3swords14.xb', | |
'littlebitspace-4coins01.xb', | |
'littlebitspace-4coins02.xb', | |
'littlebitspace-4coins03.xb', | |
'littlebitspace-4coins04.xb', | |
'littlebitspace-4coins05.xb', | |
'littlebitspace-4coins06.xb', | |
'littlebitspace-4coins07.xb', | |
'littlebitspace-4coins08.xb', | |
'littlebitspace-4coins09.xb', | |
'littlebitspace-4coins10.xb', | |
'littlebitspace-4coins11.xb', | |
'littlebitspace-4coins12.xb', | |
'littlebitspace-4coins13.xb', | |
'littlebitspace-4coins14.xb', | |
] | |
# width of a card is 17 characters | |
CARD_W = 17 | |
def main(): | |
parent = os.path.join(Path(__file__).resolve().parent, 'tarot') | |
cards = CARDS[:] | |
attr_map = AttrMap.true | |
# Conversion method from PETSCII to unicode (what is best depends on the terminal font). | |
glyph_map = GlyphMap.petscii | |
#glyph_map = GlyphMap.c64_pro_mono | |
random.shuffle(cards) | |
if len(sys.argv) > 1: | |
num = int(sys.argv[1]) | |
images = [] | |
for filename in cards[0:num]: | |
with open(os.path.join(parent, filename), 'rb') as f: | |
images.append(XBin.parse(f)) | |
dump_mult(images, CARD_W + 1, attr_map, glyph_map) | |
else: | |
with open(os.path.join(parent, cards[0]), 'rb') as f: | |
xb = XBin.parse(f) | |
dump(xb, attr_map, glyph_map) | |
if __name__ == '__main__': | |
main() |
Neat, thank you! That's an interesting comparison. I hadn't expected so much difference in the rendering of these box drawing and line drawing characters per font as I assumed they'd come from a fallback font (in general). But apparently there is!
compared to C64ProMono which seems to have a squarish aspect rati
Yea… C64 Pro Mono is most true to the original C64 in having a square aspect ratio. It's also interesting that it has the glyphs directly mapped in the Private Use Area at U+EE00 or so, so a more straightforward mapping can be used.
@vmedea Tarot card selection mode is now in main branch of arttime. The keybinding o
has been kept as an easter egg for multiple reasons so new users don't get caught up in trying to resolve a problem with Unicode Symbols for legacy computing not found in their current font instead of trying the main features of the application. Feel free to give it a try.
@vmedea More testing shows that there might be a bug in parse_xbin.py. Here's a link to zip folder in which I intend to maintain test files: arttime_test.zip. The file test1.xb looks like this when opened in any ANSI viewer:
With parse_xbin.py it looks like this (please ignore the font aspect ratio):
I'll take a look! I think the underlying issue is that xbin encodes a font internally, and not all of them use the same font index for the same glyphs. Even if they're from the same C64 set. I'm not entirely sure how to be smarter about this.
I guess we could create a map from font character data to unicode for known glyphs, instead of relyiing on indices.
It looks like that particular file uses DOS codepage 437. For now, I have added a mapping for that specifically (as it's so common for ASCII art) and updated the gist and added command line arguments, including one to specify the mapping to use
usage: parse_xbin.py [-h] [--print-font] [--mapping MAPPING] filename
positional arguments:
filename xbin file to display
optional arguments:
-h, --help show this help message and exit
--print-font, -f Print font.
--mapping MAPPING, -m MAPPING
Which mapping to use (one of "cp437", "petscii" or "petscii_c64_pro_mono", default: petscii).
@vmedea Works great! I wonder how is foreground and background color mapped in xbin's attribute. Arttime stores files with 16-color escape sequences for artists that are on board to support viewing in user's palette of choice and accessibility needs, check vim-dichromatic, color blindness for instance. While doing so it also informs users on how to configure palette to view art in original colors, check what arttime wiki says on colors.
I tried to tweak your function as_ansi_cp437
, assuming the upper 4 bits form background color and lower form foreground color. But to my surprise, something if off. In my testing, the code is correct if the bit pattens are binary encoded color numbers of 16-color palette. So the question is how to interpret the 4 bits of color, and could you add a switch to specify if user wants 16-color escape sequences or 24-bit RGB escape sequences? Thanks!!!
def as_ansi_cp437(pal, glyph, attr):
'''
General unicode approximation of DOS cp437.
'''
attrf = attr & 15
fg = 30 + (attrf & 7) + (attrf >> 3) * 60
attrb = (attr >> 4) & 15
bg = 40 + (attrb & 7) + (attrb >> 3) * 60
return f'\x1b[{fg}m\x1b[{bg}m{cp437_mapping[glyph]}'
Ok, I think I got it. Bits in XBin seem to be in CGA's IRGB order, which is different in bit notation of 16-color ANSI's color code (which is IBGR). Reversing the bit order for lower 3 bits does the trick. Here's the code, not sure if this can be optimized further in python and if it is even worth it.
def as_ansi_cp437(pal, glyph, attr):
'''
General unicode approximation of DOS cp437.
'''
attrf = attr & 15
attrf = (attrf&1)*4 + ((attrf&2)>>1)*2 + ((attrf&4)>>2)*1 + (attrf&8)
fg = 30 + (attrf & 7) + (attrf >> 3) * 60
attrb = (attr >> 4) & 15
attrb = (attrb&1)*4 + ((attrb&2)>>1)*2 + ((attrb&4)>>2)*1 + (attrb&8)
bg = 40 + (attrb & 7) + (attrb >> 3) * 60
return f'\x1b[{fg}m\x1b[{bg}m{cp437_mapping[glyph]}'
Let me know if and when you add an option for selecting between 24-bit RGB and ANSI 16-color sequences. Thanks!
Looks ike a classic BGR swap 😅 The mapping for the standard VGA 16 palette to 16 color ANSI is something like
{
# vga fg bg
0: (30, 40), # black
1: (34, 44), # dark blue
2: (32, 42), # dark green
3: (36, 46), # dark cyan
4: (31, 41), # dark red
5: (35, 45), # dark magenta
6: (33, 43), # dark yellow/brown
7: (37, 47), # grey
8: (90, 100), # dark grey
9: (94, 104), # light blue
10: (92, 102), # light green
11: (96, 106), # light cyan
12: (91, 101), # light red
13: (95, 105), # light magenta
14: (93, 103), # light yellow
15: (97, 107), # white
}
Note: untested, i'll probably get around to tomorrow.
Edit: oh, you basically did the same. Strange then.
@vmedea Seems my second comment landed few minutes before yours. The code in it works as far as my testing shows (tested on few files). Thanks! :)
@vmedea "Edit: oh, you basically did the same. Strange then." The function in second comment fixed things, sorry if I wasn't clear. Here's the image that shows image colors look good with the updated function in second comment.
Also, it would be great if each line also began with RESET, you already append RESET at the end. That way the output text image won't be affected by style of contents around it at all, as if it were an independent self-contained element for terminal.
Lol yes I was confused by the second comment! I didn't notice it was posted around the time of mine. Glad you figured it out too.
not sure if this can be optimized further in python and if it is even worth it.
I'd day optimizing the runtime isn't really worth it, these aren't huge images. The only potentially useful thing in terms of optimization I can think of is to reduce the size of the emitted output by keeping track of state within a line.
Also, it would be great if each line also began with RESET, you already append RESET at the end.
Yes that makes sense to make sure terminal is in a consistent state. I have the reset at the end of each line to prevent the background from 'bleeding'.
I've separated out attribute and glyph mapping, try -a cga16
to get the alternative palette mapping for xbins with the appropriate palette. I've also optimized the output function so that no duplicate state updates are emitted, except RESET at begin and end of every line.
@vmedea Awesome! Btw, it would be nice to have an option to disable that optimization, so fg/bg is printed for every character. This has few advantages. It will be: 1) easier to test & tinker with the output, 2) it will make it easier to cut a rectangle to show a slice of it on terminal that cannot show its entire width. If each glyph is not preceded by its color then it will be difficult to cut the art vertically. In my testing on the biggest art file I could find, your optimization cuts the size of file to half. Even the most unix-compliant compression (while not the best one) reduces the file size by 10 times... It would make the most sense for an application to compress and store the unoptimized version of art file, and decompress it in run time to get a more flexible representation of art.
@vmedea Text with reverse foreground/background is not displayed well. Check the line 74 column 4, etc in test2.xb inside the zip folder: arttime_test.zip. Screenshots attached below. Command I used:
./parse_xbin.py --attr-mapping cga16 --glyph-mapping cp437 ./test2.xb | less -R
it would be nice to have an option to disable that optimization
OK I've removed it. Seems unnecessary complexity anyway, I'd guess no one is using this with 2400 baud connections.
Text with reverse foreground/background is not displayed well.
It looks like that particular .xb has a font with reverse-video characters in some part of the range (0x01..0x1f,0x80…0x9f). I don't think I've seen this particular mapping before. But this explains why they're mapped to weird characters. Whatever it is, it's not codepage 437.
@vmedea Yes that is weird, the file opens well in xbin viewers and shows reverse video. If you feel like, check Synchronet extensions. The table about flag header fields shows somehow they could be mapping one of the 3 top bits to mean reverse video. Though what I don't understand is how such data would get mapped to where in the file reverse video is used.
Err... Seems like my point was misunderstood. It's a great feat to have the state tracking based optimization that you do, IMHO that optimization will be much appreciated by projects that cannot rely on saving the art files in a compressed format for various reasons. My understanding was slightly off, the compression format and compression/decompression utilities are not in mandatory part of POSIX. It is currently in "XSI" extensions of POSIX, which makes me reconsider if it is a good idea to rely on external compression utilities. Also, it will be appreciated by users who want a "fixed"/"equivalent" .ans file for modern unix terminals, as it will remain about the same size with your optimization applied. An option to turn on/off optimization would be great to have.
Yes that is weird, the file opens well in xbin viewers and shows reverse video.
I suppose that's because they graphically render the embedded font, instead of doing this awkward unicode mapping step? 😄
Are there more files like this or is this a unique thing? I don't think DOS ever had attributes mapped into font indices, even MDA had a dedicated attribute byte per character.
The table about flag header fields shows somehow they could be mapping 3 top bits to mean reverse video
It's the top bit in case of PETSCII. But it's not the top bit(s) in this case.
Looking at the --print-font
output,
- 0x01..0x1f is "@abcdefghijklmnopqrstuvwxyz[]^" in reverse-video (these are ASCII 0x40..0x5e)
- 0x20..0x7e is normal ASCII
- 0x7f is some diagonal stripey
- 0x80..0x9f seem characters 0xc0…0xdf in reverse-video
- 0xa0 is a full block (or space in reverse-video)
- 0xa1..0xff seems like in ISO8859-1 https://en.wikipedia.org/wiki/ISO/IEC_8859-1
So it's ISO8859-1 but with alternative characters in the undefined/control code areas. If this is a more common mapping we could add it. Or some way to pass in a custom mapping file otherwise.
Err... Seems like my point was misunderstood. It's a great feat to have the state tracking based optimization that you do
I didn't have the time to think of an internal design that integrates both options and wasn't an if-else-forest. Thinking of it now it's quite easy: have a LineBuilder
subclass OptimizedlineBuilder
or so. The interface is the same. I'll look at this.
I've updated it to add a JSON format for supplying a glyph map, which can be loaded with -m custom:…
.
Get test2.json then you can do, say:
$ ./parse_xbin.py -a cga16 -m custom:poetaman/arttime_test/test2.json poetaman/arttime_test/test2.xb
And it works as expected.
Also there's -O
to specify optimization.
@vmedea Works wonderfully well! Btw, what is the format for json?
Works wonderfully well! Btw, what is the format for json?
It's an array of 256 entries ["glyph", flags]
(one for every font slot). flags
can only be 0 or 1 at the moment which indicates reverse-video. But if something turns up with bold or underline or whatever glyphs, bit flags could be added for those.
Screenshot shows how PETSCII looks in modern fonts, all of which have a rectangular aspect ratio, compared to C64ProMono which seems to have a squarish aspect ratio. File name in titlebar carries font name.