#!/usr/bin/python3 # The MIT License (MIT) # # Copyright © 2020-2025 pacman64 # # Permission is hereby granted, free of charge, to any person obtaining a copy # of this software and associated documentation files (the “Software”), to deal # in the Software without restriction, including without limitation the rights # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell # copies of the Software, and to permit persons to whom the Software is # furnished to do so, subject to the following conditions: # # The above copyright notice and this permission notice shall be included in # all copies or substantial portions of the Software. # # THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE # SOFTWARE. from os import fstat from sys import argv, exit, stderr, stdin, stdout info = ''' nhex [options...] [filepaths/URIs...] Nice HEXadecimals is a byte-viewer which shows bytes as base-16 values, using various ANSI styles to color-code output. Output lines end with a panel showing all ASCII sequences detected along: each such panel also includes all ASCII from the next row as well, since not doing that would make grepping/matching whole strings less reliable, as some matches may be missed simply due to the narrowness of the panel. Options, where leading double-dashes are also allowed: -h show this help message -help same as -h -n narrow output, which fits 80-column mode -narrow same as -n ''' # bytes2styled_hex has `pre-rendered` strings for each possible byte bytes2styled_hex = ( '\x1b[38;2;135;175;255m00 ', '\x1b[38;2;148;148;148m01 ', '\x1b[38;2;148;148;148m02 ', '\x1b[38;2;148;148;148m03 ', '\x1b[38;2;148;148;148m04 ', '\x1b[38;2;148;148;148m05 ', '\x1b[38;2;148;148;148m06 ', '\x1b[38;2;148;148;148m07 ', '\x1b[38;2;148;148;148m08 ', '\x1b[38;2;6;152;154m09\x1b[38;2;78;78;78m ', '\x1b[38;2;6;152;154m0a\x1b[38;2;78;78;78m ', '\x1b[38;2;148;148;148m0b ', '\x1b[38;2;148;148;148m0c ', '\x1b[38;2;6;152;154m0d\x1b[38;2;78;78;78m ', '\x1b[38;2;148;148;148m0e ', '\x1b[38;2;148;148;148m0f ', '\x1b[38;2;148;148;148m10 ', '\x1b[38;2;148;148;148m11 ', '\x1b[38;2;148;148;148m12 ', '\x1b[38;2;148;148;148m13 ', '\x1b[38;2;148;148;148m14 ', '\x1b[38;2;148;148;148m15 ', '\x1b[38;2;148;148;148m16 ', '\x1b[38;2;148;148;148m17 ', '\x1b[38;2;148;148;148m18 ', '\x1b[38;2;148;148;148m19 ', '\x1b[38;2;148;148;148m1a ', '\x1b[38;2;148;148;148m1b ', '\x1b[38;2;148;148;148m1c ', '\x1b[38;2;148;148;148m1d ', '\x1b[38;2;148;148;148m1e ', '\x1b[38;2;148;148;148m1f ', '\x1b[38;2;6;152;154m20\x1b[38;2;78;78;78m ', '\x1b[38;2;102;175;135m21\x1b[38;2;78;78;78m!', '\x1b[38;2;102;175;135m22\x1b[38;2;78;78;78m"', '\x1b[38;2;102;175;135m23\x1b[38;2;78;78;78m#', '\x1b[38;2;102;175;135m24\x1b[38;2;78;78;78m$', '\x1b[38;2;102;175;135m25\x1b[38;2;78;78;78m%', '\x1b[38;2;102;175;135m26\x1b[38;2;78;78;78m&', '\x1b[38;2;102;175;135m27\x1b[38;2;78;78;78m\'', '\x1b[38;2;102;175;135m28\x1b[38;2;78;78;78m(', '\x1b[38;2;102;175;135m29\x1b[38;2;78;78;78m)', '\x1b[38;2;102;175;135m2a\x1b[38;2;78;78;78m*', '\x1b[38;2;102;175;135m2b\x1b[38;2;78;78;78m+', '\x1b[38;2;102;175;135m2c\x1b[38;2;78;78;78m,', '\x1b[38;2;102;175;135m2d\x1b[38;2;78;78;78m-', '\x1b[38;2;102;175;135m2e\x1b[38;2;78;78;78m.', '\x1b[38;2;102;175;135m2f\x1b[38;2;78;78;78m/', '\x1b[38;2;102;175;135m30\x1b[38;2;78;78;78m0', '\x1b[38;2;102;175;135m31\x1b[38;2;78;78;78m1', '\x1b[38;2;102;175;135m32\x1b[38;2;78;78;78m2', '\x1b[38;2;102;175;135m33\x1b[38;2;78;78;78m3', '\x1b[38;2;102;175;135m34\x1b[38;2;78;78;78m4', '\x1b[38;2;102;175;135m35\x1b[38;2;78;78;78m5', '\x1b[38;2;102;175;135m36\x1b[38;2;78;78;78m6', '\x1b[38;2;102;175;135m37\x1b[38;2;78;78;78m7', '\x1b[38;2;102;175;135m38\x1b[38;2;78;78;78m8', '\x1b[38;2;102;175;135m39\x1b[38;2;78;78;78m9', '\x1b[38;2;102;175;135m3a\x1b[38;2;78;78;78m:', '\x1b[38;2;102;175;135m3b\x1b[38;2;78;78;78m;', '\x1b[38;2;102;175;135m3c\x1b[38;2;78;78;78m<', '\x1b[38;2;102;175;135m3d\x1b[38;2;78;78;78m=', '\x1b[38;2;102;175;135m3e\x1b[38;2;78;78;78m>', '\x1b[38;2;102;175;135m3f\x1b[38;2;78;78;78m?', '\x1b[38;2;102;175;135m40\x1b[38;2;78;78;78m@', '\x1b[38;2;102;175;135m41\x1b[38;2;78;78;78mA', '\x1b[38;2;102;175;135m42\x1b[38;2;78;78;78mB', '\x1b[38;2;102;175;135m43\x1b[38;2;78;78;78mC', '\x1b[38;2;102;175;135m44\x1b[38;2;78;78;78mD', '\x1b[38;2;102;175;135m45\x1b[38;2;78;78;78mE', '\x1b[38;2;102;175;135m46\x1b[38;2;78;78;78mF', '\x1b[38;2;102;175;135m47\x1b[38;2;78;78;78mG', '\x1b[38;2;102;175;135m48\x1b[38;2;78;78;78mH', '\x1b[38;2;102;175;135m49\x1b[38;2;78;78;78mI', '\x1b[38;2;102;175;135m4a\x1b[38;2;78;78;78mJ', '\x1b[38;2;102;175;135m4b\x1b[38;2;78;78;78mK', '\x1b[38;2;102;175;135m4c\x1b[38;2;78;78;78mL', '\x1b[38;2;102;175;135m4d\x1b[38;2;78;78;78mM', '\x1b[38;2;102;175;135m4e\x1b[38;2;78;78;78mN', '\x1b[38;2;102;175;135m4f\x1b[38;2;78;78;78mO', '\x1b[38;2;102;175;135m50\x1b[38;2;78;78;78mP', '\x1b[38;2;102;175;135m51\x1b[38;2;78;78;78mQ', '\x1b[38;2;102;175;135m52\x1b[38;2;78;78;78mR', '\x1b[38;2;102;175;135m53\x1b[38;2;78;78;78mS', '\x1b[38;2;102;175;135m54\x1b[38;2;78;78;78mT', '\x1b[38;2;102;175;135m55\x1b[38;2;78;78;78mU', '\x1b[38;2;102;175;135m56\x1b[38;2;78;78;78mV', '\x1b[38;2;102;175;135m57\x1b[38;2;78;78;78mW', '\x1b[38;2;102;175;135m58\x1b[38;2;78;78;78mX', '\x1b[38;2;102;175;135m59\x1b[38;2;78;78;78mY', '\x1b[38;2;102;175;135m5a\x1b[38;2;78;78;78mZ', '\x1b[38;2;102;175;135m5b\x1b[38;2;78;78;78m[', '\x1b[38;2;102;175;135m5c\x1b[38;2;78;78;78m\\', '\x1b[38;2;102;175;135m5d\x1b[38;2;78;78;78m]', '\x1b[38;2;102;175;135m5e\x1b[38;2;78;78;78m^', '\x1b[38;2;102;175;135m5f\x1b[38;2;78;78;78m_', '\x1b[38;2;102;175;135m60\x1b[38;2;78;78;78m`', '\x1b[38;2;102;175;135m61\x1b[38;2;78;78;78ma', '\x1b[38;2;102;175;135m62\x1b[38;2;78;78;78mb', '\x1b[38;2;102;175;135m63\x1b[38;2;78;78;78mc', '\x1b[38;2;102;175;135m64\x1b[38;2;78;78;78md', '\x1b[38;2;102;175;135m65\x1b[38;2;78;78;78me', '\x1b[38;2;102;175;135m66\x1b[38;2;78;78;78mf', '\x1b[38;2;102;175;135m67\x1b[38;2;78;78;78mg', '\x1b[38;2;102;175;135m68\x1b[38;2;78;78;78mh', '\x1b[38;2;102;175;135m69\x1b[38;2;78;78;78mi', '\x1b[38;2;102;175;135m6a\x1b[38;2;78;78;78mj', '\x1b[38;2;102;175;135m6b\x1b[38;2;78;78;78mk', '\x1b[38;2;102;175;135m6c\x1b[38;2;78;78;78ml', '\x1b[38;2;102;175;135m6d\x1b[38;2;78;78;78mm', '\x1b[38;2;102;175;135m6e\x1b[38;2;78;78;78mn', '\x1b[38;2;102;175;135m6f\x1b[38;2;78;78;78mo', '\x1b[38;2;102;175;135m70\x1b[38;2;78;78;78mp', '\x1b[38;2;102;175;135m71\x1b[38;2;78;78;78mq', '\x1b[38;2;102;175;135m72\x1b[38;2;78;78;78mr', '\x1b[38;2;102;175;135m73\x1b[38;2;78;78;78ms', '\x1b[38;2;102;175;135m74\x1b[38;2;78;78;78mt', '\x1b[38;2;102;175;135m75\x1b[38;2;78;78;78mu', '\x1b[38;2;102;175;135m76\x1b[38;2;78;78;78mv', '\x1b[38;2;102;175;135m77\x1b[38;2;78;78;78mw', '\x1b[38;2;102;175;135m78\x1b[38;2;78;78;78mx', '\x1b[38;2;102;175;135m79\x1b[38;2;78;78;78my', '\x1b[38;2;102;175;135m7a\x1b[38;2;78;78;78mz', '\x1b[38;2;102;175;135m7b\x1b[38;2;78;78;78m{', '\x1b[38;2;102;175;135m7c\x1b[38;2;78;78;78m|', '\x1b[38;2;102;175;135m7d\x1b[38;2;78;78;78m}', '\x1b[38;2;102;175;135m7e\x1b[38;2;78;78;78m~', '\x1b[38;2;148;148;148m7f ', '\x1b[38;2;148;148;148m80 ', '\x1b[38;2;148;148;148m81 ', '\x1b[38;2;148;148;148m82 ', '\x1b[38;2;148;148;148m83 ', '\x1b[38;2;148;148;148m84 ', '\x1b[38;2;148;148;148m85 ', '\x1b[38;2;148;148;148m86 ', '\x1b[38;2;148;148;148m87 ', '\x1b[38;2;148;148;148m88 ', '\x1b[38;2;148;148;148m89 ', '\x1b[38;2;148;148;148m8a ', '\x1b[38;2;148;148;148m8b ', '\x1b[38;2;148;148;148m8c ', '\x1b[38;2;148;148;148m8d ', '\x1b[38;2;148;148;148m8e ', '\x1b[38;2;148;148;148m8f ', '\x1b[38;2;148;148;148m90 ', '\x1b[38;2;148;148;148m91 ', '\x1b[38;2;148;148;148m92 ', '\x1b[38;2;148;148;148m93 ', '\x1b[38;2;148;148;148m94 ', '\x1b[38;2;148;148;148m95 ', '\x1b[38;2;148;148;148m96 ', '\x1b[38;2;148;148;148m97 ', '\x1b[38;2;148;148;148m98 ', '\x1b[38;2;148;148;148m99 ', '\x1b[38;2;148;148;148m9a ', '\x1b[38;2;148;148;148m9b ', '\x1b[38;2;148;148;148m9c ', '\x1b[38;2;148;148;148m9d ', '\x1b[38;2;148;148;148m9e ', '\x1b[38;2;148;148;148m9f ', '\x1b[38;2;148;148;148ma0 ', '\x1b[38;2;148;148;148ma1 ', '\x1b[38;2;148;148;148ma2 ', '\x1b[38;2;148;148;148ma3 ', '\x1b[38;2;148;148;148ma4 ', '\x1b[38;2;148;148;148ma5 ', '\x1b[38;2;148;148;148ma6 ', '\x1b[38;2;148;148;148ma7 ', '\x1b[38;2;148;148;148ma8 ', '\x1b[38;2;148;148;148ma9 ', '\x1b[38;2;148;148;148maa ', '\x1b[38;2;148;148;148mab ', '\x1b[38;2;148;148;148mac ', '\x1b[38;2;148;148;148mad ', '\x1b[38;2;148;148;148mae ', '\x1b[38;2;148;148;148maf ', '\x1b[38;2;148;148;148mb0 ', '\x1b[38;2;148;148;148mb1 ', '\x1b[38;2;148;148;148mb2 ', '\x1b[38;2;148;148;148mb3 ', '\x1b[38;2;148;148;148mb4 ', '\x1b[38;2;148;148;148mb5 ', '\x1b[38;2;148;148;148mb6 ', '\x1b[38;2;148;148;148mb7 ', '\x1b[38;2;148;148;148mb8 ', '\x1b[38;2;148;148;148mb9 ', '\x1b[38;2;148;148;148mba ', '\x1b[38;2;148;148;148mbb ', '\x1b[38;2;148;148;148mbc ', '\x1b[38;2;148;148;148mbd ', '\x1b[38;2;148;148;148mbe ', '\x1b[38;2;148;148;148mbf ', '\x1b[38;2;148;148;148mc0 ', '\x1b[38;2;148;148;148mc1 ', '\x1b[38;2;148;148;148mc2 ', '\x1b[38;2;148;148;148mc3 ', '\x1b[38;2;148;148;148mc4 ', '\x1b[38;2;148;148;148mc5 ', '\x1b[38;2;148;148;148mc6 ', '\x1b[38;2;148;148;148mc7 ', '\x1b[38;2;148;148;148mc8 ', '\x1b[38;2;148;148;148mc9 ', '\x1b[38;2;148;148;148mca ', '\x1b[38;2;148;148;148mcb ', '\x1b[38;2;148;148;148mcc ', '\x1b[38;2;148;148;148mcd ', '\x1b[38;2;148;148;148mce ', '\x1b[38;2;148;148;148mcf ', '\x1b[38;2;148;148;148md0 ', '\x1b[38;2;148;148;148md1 ', '\x1b[38;2;148;148;148md2 ', '\x1b[38;2;148;148;148md3 ', '\x1b[38;2;148;148;148md4 ', '\x1b[38;2;148;148;148md5 ', '\x1b[38;2;148;148;148md6 ', '\x1b[38;2;148;148;148md7 ', '\x1b[38;2;148;148;148md8 ', '\x1b[38;2;148;148;148md9 ', '\x1b[38;2;148;148;148mda ', '\x1b[38;2;148;148;148mdb ', '\x1b[38;2;148;148;148mdc ', '\x1b[38;2;148;148;148mdd ', '\x1b[38;2;148;148;148mde ', '\x1b[38;2;148;148;148mdf ', '\x1b[38;2;148;148;148me0 ', '\x1b[38;2;148;148;148me1 ', '\x1b[38;2;148;148;148me2 ', '\x1b[38;2;148;148;148me3 ', '\x1b[38;2;148;148;148me4 ', '\x1b[38;2;148;148;148me5 ', '\x1b[38;2;148;148;148me6 ', '\x1b[38;2;148;148;148me7 ', '\x1b[38;2;148;148;148me8 ', '\x1b[38;2;148;148;148me9 ', '\x1b[38;2;148;148;148mea ', '\x1b[38;2;148;148;148meb ', '\x1b[38;2;148;148;148mec ', '\x1b[38;2;148;148;148med ', '\x1b[38;2;148;148;148mee ', '\x1b[38;2;148;148;148mef ', '\x1b[38;2;148;148;148mf0 ', '\x1b[38;2;148;148;148mf1 ', '\x1b[38;2;148;148;148mf2 ', '\x1b[38;2;148;148;148mf3 ', '\x1b[38;2;148;148;148mf4 ', '\x1b[38;2;148;148;148mf5 ', '\x1b[38;2;148;148;148mf6 ', '\x1b[38;2;148;148;148mf7 ', '\x1b[38;2;148;148;148mf8 ', '\x1b[38;2;148;148;148mf9 ', '\x1b[38;2;148;148;148mfa ', '\x1b[38;2;148;148;148mfb ', '\x1b[38;2;148;148;148mfc ', '\x1b[38;2;148;148;148mfd ', '\x1b[38;2;148;148;148mfe ', '\x1b[38;5;209mff ', ) # int2ascii slightly speeds up func show_ascii int2ascii = tuple(chr(i) if 32 <= i < 127 else ' ' for i in range(256)) # visible noticeably speeds up func show_ascii; notice how spaces (code 32) # aren't considered visible symbols, which makes sense in func show_ascii visible = tuple(32 < i < 127 for i in range(256)) def show_hex(w, src, chunk_size: int = 16) -> None: 'Handle all input from the source given, emitting styled output.' # make the ruler/line-breather, which shows up every 5 hex-output lines pre = 8 * ' ' pat = ' ·' pat = int(3 * chunk_size / len(pat)) * pat sep_line = f'{pre} \x1b[38;5;245m{pat}\x1b[0m\n' # n is the current byte offset shown at the start of each display line n = 0 # lines keeps track of the main output line/row count, to figure out # when to put `breather` lines lines = 0 # prev remembers the previous chunk, as showing ASCII content for # 2 output-lines worth of bytes requires staying 1 step behind, so # to speak prev = src.read(chunk_size) if len(prev) == 0: return while True: chunk = src.read(chunk_size) if len(chunk) == 0: break if lines % 5 == 0 and lines > 0: w.write(sep_line) show_line(w, n, prev, chunk, chunk_size) n += len(prev) prev = chunk lines += 1 # don't forget the last output line if len(prev) > 0: if lines % 5 == 0 and lines > 0: w.write(sep_line) show_line(w, n, prev, bytes(), chunk_size) def show_line(w, n: int, prev, chunk, chunk_size: int) -> None: 'Help func show_hex do its job, simplifying its control flow.' # w.write(f'{n:8} \x1b[48;5;254m') show_restyled_uint(w, n, 8) w.write(' \x1b[48;5;254m') for e in prev: w.write(bytes2styled_hex[e]) w.write('\x1b[0m') show_ascii(w, prev, chunk, 3 * (chunk_size - len(prev)) + 2) w.write('\n') def show_restyled_uint(w, n: int, width: int) -> None: 'Alternate styles on 3-item chunks of digits from the integer given.' digits = str(n) l = len(digits) # left-pad digits with spaces to fill the output-width given write_spaces(w, width - l) # it's quicker to just emit short-enough digit-runs verbatim if l < 4: w.write(digits) return # emit leading chunk of digits, which is the only one which # can have fewer than 3 items lead = l % 3 w.write(digits[:lead]) # the rest of the string now has a multiple of 3 items left start = lead # start by styling the next digit-group only if there was a # non-empty leading group at the start of the full digit-run use_style = lead > 0 # alternate styles until the string is over while start < l: # the digits left are always a multiple of 3 stop = start + 3 if use_style: w.write('\x1b[38;5;248m') w.write(digits[start:stop]) w.write('\x1b[0m') else: w.write(digits[start:stop]) # switch style and advance to the next 3-digit chunk use_style = not use_style start = stop def show_ascii(w, first, second: bytes, pre: int) -> None: 'Emit the ASCII side-panel for func show_hex.' # prev_vis keeps track of the previous byte's `visibility`, so spaces # are added when bytes change from non-visible-ASCII to visible-ASCII prev_vis = False is_vis = False spaces = pre # show ASCII symbols from the first `line` in the pair for e in first: is_vis = visible[e] if is_vis: if not prev_vis: write_spaces(w, spaces) spaces = 1 w.write(int2ascii[e]) prev_vis = is_vis # do the same for the second `line` in the pair for e in second: is_vis = visible[e] if is_vis: if not prev_vis: write_spaces(w, spaces) spaces = 1 w.write(int2ascii[e]) prev_vis = is_vis def write_spaces(w, n: int) -> None: 'Emit the number of spaces given, minimizing `write` calls.' if n < 1: return if n < len(spaces): w.write(spaces[n]) return while n >= len(spaces): w.write(spaces[-1]) n -= len(spaces) w.write(spaces[n]) def seems_url(s: str) -> bool: protocols = ('https://', 'http://', 'file://', 'ftp://', 'data:') return any(s.startswith(p) for p in protocols) # args is the `proper` list of arguments given to the script args = argv[1:] # a leading help-option arg means show the help message and quit if len(args) > 0 and args[0] in ('-h', '--h', '-help', '--help'): print(info.strip(), file=stderr) exit(0) # narrow-output is to fit results in 80-column mode bytes_per_line = 16 if len(args) > 0 and args[0] in ('-n', '--n', '-narrow', '--narrow'): bytes_per_line = 12 args = args[1:] elif len(args) > 0: # allow a leading integer argument to set exactly how many bytes per # line to show in the styled output, before the ASCII-panel contents try: # try to parse an integer number, after turning double-dashes # into single ones, which may lead to parsed negative integers n = int(args[0].lstrip('-')) # negative integers are a result of option-style leading dashes n = int(abs(n)) if n > 0: # only change the width-setting if leading number isn't zero bytes_per_line = n # don't treat a leading integer as a filepath, no matter what args = args[1:] except Exception: # avoid exceptions if leading arg isn't a valid integer pass # spaces lets func write_spaces minimize `write` operations, resulting in # noticeable speed-ups when the script deals with megabytes of data spaces = tuple(i * ' ' for i in range(3 * bytes_per_line + 4)) try: if args.count('-') > 1: msg = 'reading from `-` (standard input) more than once not allowed' raise ValueError(msg) if any(seems_url(e) for e in args): from urllib.request import urlopen for i, path in enumerate(args): if i > 0: stdout.write('\n') stdout.write('\n') if path == '-': stdout.write('• - ()\n') stdout.write('\n') show_hex(stdout, stdin.buffer, bytes_per_line) continue if seems_url(path): with urlopen(path) as inp: stdout.write(f'• {path}\n') stdout.write('\n') show_hex(stdout, inp, bytes_per_line) continue with open(path, mode='rb', buffering=4_960) as inp: n = fstat(inp.fileno()).st_size stdout.write(f'• {path} \x1b[38;5;245m({n:,} bytes)\x1b[0m\n') stdout.write('\n') show_hex(stdout, inp, bytes_per_line) if len(args) == 0: stdout.write('• \n') stdout.write('\n') show_hex(stdout, stdin.buffer, bytes_per_line) except BrokenPipeError: # quit quietly, instead of showing a confusing error message stderr.close() except KeyboardInterrupt: exit(2) except Exception as e: print(f'\x1b[31m{e}\x1b[0m', file=stderr) exit(1)