"""Strict, independent decoder for HEXWORK's emitted VT subset (stdlib only). Colors are None (terminal default), indexed integers, or RGB tuples. A cell is (glyph, foreground, background, bold). Unsupported controls deliberately fail. This is not a general terminal emulator: wide/combining glyphs fail explicitly. """ import codecs import html import json import os import pathlib import unicodedata class VT: def __init__(self, width=80, height=24): self.width, self.height = width, height self.fg = self.bg = None self.bold = False self.x = self.y = 0 self.wrap = True self.pending_wrap = False self.alternate = False self.saved = None self.visible = True self.decoder = codecs.getincrementaldecoder('utf-8')('strict') self.control = '' self.cells = [[self.blank() for _ in range(width)] for _ in range(height)] def blank(self): return (' ', self.fg, self.bg, self.bold) def resize(self, width, height): assert width > 0 and height > 0 self.cells = [(row[:width] + [self.blank()] * max(0, width-len(row))) for row in self.cells[:height]] self.cells += [[self.blank() for _ in range(width)] for _ in range(height-len(self.cells))] self.width, self.height = width, height self.x, self.y = min(self.x, width-1), min(self.y, height-1) self.pending_wrap = False def feed(self, data): for char in self.decoder.decode(data): if self.control: self.control += char if self.control == '\x1b[': continue if len(self.control) == 2: raise ValueError('unsupported escape %r' % self.control) if '@' <= char <= '~': command, self.control = self.control, '' self.csi(command[2:-1], command[-1]) elif not (' ' <= char <= '?'): raise ValueError('malformed CSI %r' % self.control) continue if char == '\x1b': self.control = char elif char == '\r': self.x, self.pending_wrap = 0, False elif char == '\n': self.linefeed() elif char == '\b': self.x, self.pending_wrap = max(0, self.x-1), False elif char == '\t': self.x = min(self.width-1, (self.x//8+1)*8) self.pending_wrap = False elif ord(char) < 32 or ord(char) == 127: raise ValueError('unsupported control %r' % char) else: if unicodedata.combining(char) or unicodedata.east_asian_width(char) in ('W', 'F'): raise ValueError('unsupported cell width %r' % char) if self.pending_wrap: self.x = 0 self.linefeed() self.cells[self.y][self.x] = (char, self.fg, self.bg, self.bold) if self.x == self.width-1: self.pending_wrap = self.wrap else: self.x += 1 def finish(self): self.decoder.decode(b'', final=True) if self.control: raise ValueError('incomplete control %r' % self.control) def linefeed(self): self.pending_wrap = False self.y += 1 if self.y == self.height: self.cells.pop(0) self.cells.append([self.blank() for _ in range(self.width)]) self.y -= 1 def csi(self, params, final): if params.startswith('?') and final in 'hl': enabled = final == 'h' for mode in params[1:].split(';'): if mode == '7': self.wrap = enabled self.pending_wrap = False elif mode == '25': self.visible = enabled elif mode == '1049': if enabled and not self.alternate: self.saved = (self.cells, self.x, self.y, self.fg, self.bg, self.bold, self.pending_wrap) self.cells = [[self.blank() for _ in range(self.width)] for _ in range(self.height)] self.x = self.y = 0 self.pending_wrap = False elif not enabled and self.alternate: self.cells, self.x, self.y, self.fg, self.bg, self.bold, self.pending_wrap = self.saved self.resize(self.width, self.height) self.alternate = enabled elif mode in ('1000', '1002', '1003', '1004', '1006', '1015', '2004'): pass # input reporting modes have no display effect else: raise ValueError('unsupported private mode ' + mode) return try: values = [int(p) if p else 0 for p in params.split(';')] except ValueError as error: raise ValueError('unsupported CSI %r%s' % (params, final)) from error if final in 'Hf' and len(values) <= 2: row, col = (values + [0])[:2] self.y, self.x = min(self.height-1, max(1, row)-1), min(self.width-1, max(1, col)-1) self.pending_wrap = False elif final in 'JK' and len(values) == 1 and values[0] in (0, 1, 2): mode = values[0] cursor = self.y*self.width+self.x for y in range(self.height): for x in range(self.width): index = y*self.width+x if (final == 'J' or y == self.y) and (mode == 2 or (mode == 0 and index >= cursor) or (mode == 1 and index <= cursor)): self.cells[y][x] = self.blank() elif final == 'm': while values: value = values.pop(0) if value == 0: self.fg = self.bg = None self.bold = False elif value in (1, 22): self.bold = value == 1 elif value in (39, 49): setattr(self, 'fg' if value == 39 else 'bg', None) elif 30 <= value <= 37 or 90 <= value <= 97: self.fg = value-30 if value < 90 else value-90+8 elif 40 <= value <= 47 or 100 <= value <= 107: self.bg = value-40 if value < 100 else value-100+8 elif value in (38, 48): if not values: raise ValueError('missing SGR color mode') mode = values.pop(0) count = {2: 3, 5: 1}.get(mode) if count is None or len(values) < count or any(not 0 <= v <= 255 for v in values[:count]): raise ValueError('invalid SGR color') color = tuple(values[:count]) if count == 3 else values[0] del values[:count] setattr(self, 'fg' if value == 38 else 'bg', color) else: raise ValueError('unsupported SGR %d' % value) else: raise ValueError('unsupported CSI %r%s' % (params, final)) def text(self): return '\n'.join(''.join(cell[0] for cell in row) for row in self.cells) def snapshot(self): return {'width': self.width, 'height': self.height, 'cell_aspect': [1, 2], 'cursor': [self.x, self.y], 'cursor_visible': self.visible, 'terminal_defaults': {'fg': None, 'bg': None}, 'pen': [self.fg, self.bg, self.bold], 'alternate': self.alternate, 'cells': self.cells} def rgb(color, default): if color is None: return default if isinstance(color, tuple): return '#%02x%02x%02x' % color basic = ('#000000','#800000','#008000','#808000','#000080','#800080','#008080','#c0c0c0', '#808080','#ff0000','#00ff00','#ffff00','#0000ff','#ff00ff','#00ffff','#ffffff') if color < 16: return basic[color] if color >= 232: return '#%02x%02x%02x' % ((8+10*(color-232),)*3) n = color-16 levels = (0,95,135,175,215,255) return '#%02x%02x%02x' % (levels[n//36], levels[n//6%6], levels[n%6]) METADATA_KEYS = frozenset(('frame','mode','look_active','yaw','pitch','elevation','selected_z', 'player_z','tick','diagnostic_hash','history_len','width','height', 'color_mode','mouse_capture','suspended')) def export(vt, name, metadata): """No output unless opted in. No paths, timestamps, IDs or raw bytes exported.""" root = os.environ.get('HEXWORK_PTY_ARTIFACT_DIR') if not root: return if not name or any(c not in 'abcdefghijklmnopqrstuvwxyz0123456789-_' for c in name): raise ValueError('unsafe artifact name') clean = {key: value for key, value in metadata.items() if key in METADATA_KEYS} for key, value in clean.items(): if isinstance(value, str) and (len(value) > 128 or any(ord(c) < 32 for c in value) or '/' in value or '\\' in value): raise ValueError('unsafe metadata: ' + key) if value is not None and not isinstance(value, (str, int, float, bool)): raise ValueError('non-scalar metadata: ' + key) path = pathlib.Path(root) path.mkdir(parents=True, exist_ok=True) payload = {'schema': 1, 'metadata': clean, 'screen': vt.snapshot()} (path/(name+'.json')).write_text(json.dumps(payload, sort_keys=True, ensure_ascii=True, allow_nan=False, separators=(',', ':'))+'\n') parts = ['' % (vt.width*10, vt.height*20, vt.width*10, vt.height*20), ''+html.escape(json.dumps({'cell_aspect':[1,2], 'metadata':clean}, sort_keys=True))+''] for y, row in enumerate(vt.cells): for x, (glyph, fg, bg, bold) in enumerate(row): parts.append('' % (x*10,y*20,rgb(bg,'#000000'))) parts.append('%s' % (x*10,y*20+16,'bold' if bold else 'normal',rgb(fg,'#c0c0c0'),html.escape(glyph))) (path/(name+'.svg')).write_text('\n'.join(parts+[''])+'\n')