Created
September 6, 2021 15:19
-
-
Save cessor/7cb2c1f75bfe994751396ab58df0ed9f to your computer and use it in GitHub Desktop.
hexview.py
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import argparse | |
import curses | |
import string | |
import sys | |
from curses import wrapper | |
from curses.textpad import rectangle | |
from pathlib import Path | |
class HexDisplay: | |
def __init__(self, content: bytes, offset=True, chars=True): | |
self._content = content | |
self._offset = offset | |
self._chars = chars | |
def __iter__(self): | |
offset_display = '' | |
char_display = '' | |
# Split byte content in 16 byte rows | |
for i in range(0, len(self._content), 16): | |
row = self._content[i:i + 16] | |
# Optional: Display a row offset | |
if self._offset: | |
offset_display = f'{i:08X} ' | |
# Render each byte in its hexadecimal representation | |
bytes_display = ' '.join([f'{byte:02X}' for byte in row]) | |
# The last row might be shorter, so add padding | |
padding = ' '.join([' '] * (16 - len(row))) | |
# Optional: Append ascii represenation of each byte | |
if self._chars: | |
char_display = ' ' + ''.join([ | |
# ASCII if byte is printable, else "." | |
chr(byte) if 0x20 <= byte <= 0x7f else '.' for byte in row | |
]) | |
yield f'{offset_display}{bytes_display}{padding}{char_display}' | |
class Dialog: | |
HEIGHT = 5 | |
WIDTH = 24 | |
def __init__(self, title, dialog, textbox, y, x): | |
self._title = title | |
self._dialog = dialog | |
self._textbox = textbox | |
self._y = y | |
self._x = x | |
def _display(self, buffer_): | |
height, width = self.HEIGHT, self.WIDTH | |
title = self._title | |
self._dialog.clear() | |
# Outer Border | |
rectangle(self._dialog, 0, 0, height-1, width-2) | |
# Inner Border | |
rectangle(self._dialog, 1, 2, height-2, width-4) | |
# Title line: | |
center = (width // 2) - (len(title) // 2) | |
self._dialog.addstr(0, center, title) | |
self._dialog.refresh() | |
# Shift cursor to imitate typing | |
self._dialog.move(2, 4 + len(buffer_)) | |
# Text in textbox | |
self._textbox.clear() | |
self._textbox.addstr(0, 0, ''.join(buffer_)) | |
self._textbox.refresh(0, 0, | |
self._y + 2, self._x + 4, | |
self._y + 2 + 1, self._x + 4 + 15) | |
def prompt(self): | |
"""Promt a hexadecimal number""" | |
buffer_ = [] | |
while True: | |
self._display(buffer_) | |
key = self._dialog.getch() | |
if chr(key) in string.hexdigits: | |
# Accept hex values | |
buffer_.append(chr(key)) | |
continue | |
if key in (8, curses.KEY_BACKSPACE, ): | |
# Remove last item | |
if buffer_: | |
buffer_.pop() | |
continue | |
if key in (10, curses.KEY_ENTER): | |
# Calculate line number for byte offset | |
return int(''.join(buffer_), 16) // 16 | |
@classmethod | |
def load(cls, screen, pad, title): | |
height, width = cls.HEIGHT, cls.WIDTH | |
# Get height from screen, because pad might be waaayy longer | |
max_height, _ = screen.getmaxyx() | |
_, max_width = pad.getmaxyx() | |
# Center on Screen | |
dialog_y = (max_height // 2) - (height // 2) | |
dialog_x = (max_width // 2) - (width // 2) | |
# height, width, x, y, | |
dialog = curses.newwin(height, width, dialog_y, dialog_x) | |
# Textbox | |
textbox = curses.newpad(1, 16) | |
return cls(title, dialog, textbox, dialog_y, dialog_x) | |
class Window: | |
def __init__(self, screen, pad, dialog): | |
self._screen = screen | |
self._pad = pad | |
self._dialog = dialog | |
self._viewport_height, self._viewport_width = self._screen.getmaxyx() | |
self._page_height, self._page_width = self._pad.getmaxyx() | |
self._row_offset = 0 | |
def show(self): | |
self._screen.clear() | |
self._screen.refresh() | |
while True: | |
self._display() | |
# Read Input | |
key = self._screen.getch() | |
# Handle Exit | |
if key in (27, ord('q')): # ESC or Q | |
exit(0) | |
# Handle jump dialog | |
if key in (ord('g'), ord('G')): | |
line_number = self._show_jump_dialog() | |
if line_number > (self._page_height - self._viewport_height): | |
# Clamp to end | |
self._row_offset = self._page_height - self._viewport_height | |
else: | |
self._row_offset = line_number | |
continue | |
# Handle input to scroll display | |
self._row_offset = self._scroll_display(key, self._row_offset) | |
def _show_jump_dialog(self): | |
curses.curs_set(1) | |
try: | |
line_number = self._dialog.prompt() | |
except ValueError: | |
return self._row_offset | |
curses.curs_set(0) | |
return line_number | |
def _display(self): | |
# Content offset (y,x) | |
# Viewport ((y,x) top left, (y,x) bottom right | |
self._pad.refresh( | |
self._row_offset, 0, | |
0, 0, | |
self._viewport_height - 1, self._viewport_width - 1 | |
) | |
self._screen.refresh() | |
def _scroll_display(self, key, row_offset): | |
# Home | |
if key == curses.KEY_HOME: | |
return 0 | |
# Scroll Up, (Cursor or Keypad) | |
if key in (curses.KEY_UP, 259, 56): | |
return max(row_offset - 1, 0) | |
# Page Up | |
if key in (curses.KEY_PPAGE,): | |
return max(row_offset - self._viewport_height, 0) | |
# Page Down | |
if key in (curses.KEY_NPAGE,): | |
if self._viewport_height >= self._page_height: | |
return row_offset | |
return min(row_offset + self._viewport_height, (self._page_height - self._viewport_height)) | |
# Scroll Down (Cursor or Keypad) | |
if key in (curses.KEY_DOWN, 258, 50): | |
if self._viewport_height >= self._page_height: | |
return row_offset | |
return min(row_offset + 1, (self._page_height - self._viewport_height)) | |
# End | |
if key == curses.KEY_END: | |
if self._viewport_height >= self._page_height: | |
return row_offset | |
return self._page_height - self._viewport_height | |
return row_offset | |
@classmethod | |
def load(cls, screen, hex_display): | |
lines = list(hex_display) | |
rows = len(lines) | |
cols = len(lines[0]) | |
# Setup hexcode display | |
pad = curses.newpad(rows, cols) | |
for line_number, line in enumerate(lines): | |
pad.addstr(line_number, 0, line) | |
# Setup Go To Line-Dialog | |
dialog = Dialog.load(screen, pad, title="Goto Byte (hex)") | |
return Window(screen, pad, dialog) | |
def main(screen): | |
parser = argparse.ArgumentParser( | |
description='Display a binary file.' | |
) | |
parser.add_argument( | |
'file', | |
type=str, | |
help='A binary file to display' | |
) | |
parser.add_argument( | |
'-no', | |
'--no-offset', | |
action='store_false', | |
default=True, # Default to displaying | |
help='Deactivate byte offset display', | |
) | |
parser.add_argument( | |
'-na', | |
'--no-ascii', | |
action='store_false', | |
default=True, # Default to displaying | |
help='Deactivate ascii display', | |
) | |
arguments = parser.parse_args() | |
content = Path(arguments.file).read_bytes() | |
hex_display = HexDisplay( | |
content=content, | |
offset=arguments.no_offset, | |
chars=arguments.no_ascii | |
) | |
window = Window.load(screen, hex_display) | |
window.show() | |
if __name__ == '__main__': | |
wrapper(main) |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment