Skip to content

Instantly share code, notes, and snippets.

@vmedea
Last active December 15, 2022 09:19
Show Gist options
  • Save vmedea/38a227f34863bec16b7546992b5b8534 to your computer and use it in GitHub Desktop.
Save vmedea/38a227f34863bec16b7546992b5b8534 to your computer and use it in GitHub Desktop.
Command line tarot
#!/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()
@vmedea
Copy link
Author

vmedea commented Dec 9, 2022

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).

screenshot-parse-xbin

@poetaman
Copy link

poetaman commented Dec 10, 2022

@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]}'

Screenshot 2022-12-09 at 4 09 31 PM

@poetaman
Copy link

poetaman commented Dec 10, 2022

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!

@vmedea
Copy link
Author

vmedea commented Dec 10, 2022

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.

@poetaman
Copy link

poetaman commented Dec 10, 2022

@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! :)

@poetaman
Copy link

@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.

Screenshot 2022-12-09 at 6 12 52 PM

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.

@vmedea
Copy link
Author

vmedea commented Dec 10, 2022

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'.

@vmedea
Copy link
Author

vmedea commented Dec 10, 2022

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.

@poetaman
Copy link

poetaman commented Dec 11, 2022

@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.

@poetaman
Copy link

@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

Screenshot 2022-12-11 at 11 51 59 PM

Screenshot 2022-12-11 at 11 52 19 PM

@vmedea
Copy link
Author

vmedea commented Dec 13, 2022

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.

@poetaman
Copy link

poetaman commented Dec 13, 2022

@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.

@vmedea
Copy link
Author

vmedea commented Dec 14, 2022

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,

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.

@vmedea
Copy link
Author

vmedea commented Dec 14, 2022

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.

@poetaman
Copy link

@vmedea Works wonderfully well! Btw, what is the format for json?

@vmedea
Copy link
Author

vmedea commented Dec 15, 2022

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment