File: nh.py
   1 #!/usr/bin/python3
   2 
   3 # The MIT License (MIT)
   4 #
   5 # Copyright © 2020-2025 pacman64
   6 #
   7 # Permission is hereby granted, free of charge, to any person obtaining a copy
   8 # of this software and associated documentation files (the “Software”), to deal
   9 # in the Software without restriction, including without limitation the rights
  10 # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
  11 # copies of the Software, and to permit persons to whom the Software is
  12 # furnished to do so, subject to the following conditions:
  13 #
  14 # The above copyright notice and this permission notice shall be included in
  15 # all copies or substantial portions of the Software.
  16 #
  17 # THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
  18 # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
  19 # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
  20 # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
  21 # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
  22 # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
  23 # SOFTWARE.
  24 
  25 
  26 from os import fstat
  27 from sys import argv, exit, stderr, stdin, stdout
  28 
  29 
  30 info = '''
  31 nh [options...] [filepaths/URIs...]
  32 
  33 
  34 Nice Hexadecimals is a byte-viewer which shows bytes as base-16 values,
  35 using various ANSI styles to color-code output.
  36 
  37 Output lines end with a panel showing all ASCII sequences detected along:
  38 each such panel also includes all ASCII from the next row as well, since
  39 not doing that would make grepping/matching whole strings less reliable,
  40 as some matches may be missed simply due to the narrowness of the panel.
  41 
  42 Options, where leading double-dashes are also allowed:
  43 
  44     -h         show this help message
  45     -help      same as -h
  46 
  47     -n         narrow output, which fits 80-column mode
  48     -narrow    same as -n
  49 '''
  50 
  51 
  52 # bytes2styled_hex has `pre-rendered` strings for each possible byte
  53 bytes2styled_hex = (
  54     '\x1b[38;2;135;175;255m00 ',
  55     '\x1b[38;2;148;148;148m01 ',
  56     '\x1b[38;2;148;148;148m02 ',
  57     '\x1b[38;2;148;148;148m03 ',
  58     '\x1b[38;2;148;148;148m04 ',
  59     '\x1b[38;2;148;148;148m05 ',
  60     '\x1b[38;2;148;148;148m06 ',
  61     '\x1b[38;2;148;148;148m07 ',
  62     '\x1b[38;2;148;148;148m08 ',
  63     '\x1b[38;2;6;152;154m09\x1b[38;2;78;78;78m ',
  64     '\x1b[38;2;6;152;154m0a\x1b[38;2;78;78;78m ',
  65     '\x1b[38;2;148;148;148m0b ',
  66     '\x1b[38;2;148;148;148m0c ',
  67     '\x1b[38;2;6;152;154m0d\x1b[38;2;78;78;78m ',
  68     '\x1b[38;2;148;148;148m0e ',
  69     '\x1b[38;2;148;148;148m0f ',
  70     '\x1b[38;2;148;148;148m10 ',
  71     '\x1b[38;2;148;148;148m11 ',
  72     '\x1b[38;2;148;148;148m12 ',
  73     '\x1b[38;2;148;148;148m13 ',
  74     '\x1b[38;2;148;148;148m14 ',
  75     '\x1b[38;2;148;148;148m15 ',
  76     '\x1b[38;2;148;148;148m16 ',
  77     '\x1b[38;2;148;148;148m17 ',
  78     '\x1b[38;2;148;148;148m18 ',
  79     '\x1b[38;2;148;148;148m19 ',
  80     '\x1b[38;2;148;148;148m1a ',
  81     '\x1b[38;2;148;148;148m1b ',
  82     '\x1b[38;2;148;148;148m1c ',
  83     '\x1b[38;2;148;148;148m1d ',
  84     '\x1b[38;2;148;148;148m1e ',
  85     '\x1b[38;2;148;148;148m1f ',
  86     '\x1b[38;2;6;152;154m20\x1b[38;2;78;78;78m ',
  87     '\x1b[38;2;102;175;135m21\x1b[38;2;78;78;78m!',
  88     '\x1b[38;2;102;175;135m22\x1b[38;2;78;78;78m"',
  89     '\x1b[38;2;102;175;135m23\x1b[38;2;78;78;78m#',
  90     '\x1b[38;2;102;175;135m24\x1b[38;2;78;78;78m$',
  91     '\x1b[38;2;102;175;135m25\x1b[38;2;78;78;78m%',
  92     '\x1b[38;2;102;175;135m26\x1b[38;2;78;78;78m&',
  93     '\x1b[38;2;102;175;135m27\x1b[38;2;78;78;78m\'',
  94     '\x1b[38;2;102;175;135m28\x1b[38;2;78;78;78m(',
  95     '\x1b[38;2;102;175;135m29\x1b[38;2;78;78;78m)',
  96     '\x1b[38;2;102;175;135m2a\x1b[38;2;78;78;78m*',
  97     '\x1b[38;2;102;175;135m2b\x1b[38;2;78;78;78m+',
  98     '\x1b[38;2;102;175;135m2c\x1b[38;2;78;78;78m,',
  99     '\x1b[38;2;102;175;135m2d\x1b[38;2;78;78;78m-',
 100     '\x1b[38;2;102;175;135m2e\x1b[38;2;78;78;78m.',
 101     '\x1b[38;2;102;175;135m2f\x1b[38;2;78;78;78m/',
 102     '\x1b[38;2;102;175;135m30\x1b[38;2;78;78;78m0',
 103     '\x1b[38;2;102;175;135m31\x1b[38;2;78;78;78m1',
 104     '\x1b[38;2;102;175;135m32\x1b[38;2;78;78;78m2',
 105     '\x1b[38;2;102;175;135m33\x1b[38;2;78;78;78m3',
 106     '\x1b[38;2;102;175;135m34\x1b[38;2;78;78;78m4',
 107     '\x1b[38;2;102;175;135m35\x1b[38;2;78;78;78m5',
 108     '\x1b[38;2;102;175;135m36\x1b[38;2;78;78;78m6',
 109     '\x1b[38;2;102;175;135m37\x1b[38;2;78;78;78m7',
 110     '\x1b[38;2;102;175;135m38\x1b[38;2;78;78;78m8',
 111     '\x1b[38;2;102;175;135m39\x1b[38;2;78;78;78m9',
 112     '\x1b[38;2;102;175;135m3a\x1b[38;2;78;78;78m:',
 113     '\x1b[38;2;102;175;135m3b\x1b[38;2;78;78;78m;',
 114     '\x1b[38;2;102;175;135m3c\x1b[38;2;78;78;78m<',
 115     '\x1b[38;2;102;175;135m3d\x1b[38;2;78;78;78m=',
 116     '\x1b[38;2;102;175;135m3e\x1b[38;2;78;78;78m>',
 117     '\x1b[38;2;102;175;135m3f\x1b[38;2;78;78;78m?',
 118     '\x1b[38;2;102;175;135m40\x1b[38;2;78;78;78m@',
 119     '\x1b[38;2;102;175;135m41\x1b[38;2;78;78;78mA',
 120     '\x1b[38;2;102;175;135m42\x1b[38;2;78;78;78mB',
 121     '\x1b[38;2;102;175;135m43\x1b[38;2;78;78;78mC',
 122     '\x1b[38;2;102;175;135m44\x1b[38;2;78;78;78mD',
 123     '\x1b[38;2;102;175;135m45\x1b[38;2;78;78;78mE',
 124     '\x1b[38;2;102;175;135m46\x1b[38;2;78;78;78mF',
 125     '\x1b[38;2;102;175;135m47\x1b[38;2;78;78;78mG',
 126     '\x1b[38;2;102;175;135m48\x1b[38;2;78;78;78mH',
 127     '\x1b[38;2;102;175;135m49\x1b[38;2;78;78;78mI',
 128     '\x1b[38;2;102;175;135m4a\x1b[38;2;78;78;78mJ',
 129     '\x1b[38;2;102;175;135m4b\x1b[38;2;78;78;78mK',
 130     '\x1b[38;2;102;175;135m4c\x1b[38;2;78;78;78mL',
 131     '\x1b[38;2;102;175;135m4d\x1b[38;2;78;78;78mM',
 132     '\x1b[38;2;102;175;135m4e\x1b[38;2;78;78;78mN',
 133     '\x1b[38;2;102;175;135m4f\x1b[38;2;78;78;78mO',
 134     '\x1b[38;2;102;175;135m50\x1b[38;2;78;78;78mP',
 135     '\x1b[38;2;102;175;135m51\x1b[38;2;78;78;78mQ',
 136     '\x1b[38;2;102;175;135m52\x1b[38;2;78;78;78mR',
 137     '\x1b[38;2;102;175;135m53\x1b[38;2;78;78;78mS',
 138     '\x1b[38;2;102;175;135m54\x1b[38;2;78;78;78mT',
 139     '\x1b[38;2;102;175;135m55\x1b[38;2;78;78;78mU',
 140     '\x1b[38;2;102;175;135m56\x1b[38;2;78;78;78mV',
 141     '\x1b[38;2;102;175;135m57\x1b[38;2;78;78;78mW',
 142     '\x1b[38;2;102;175;135m58\x1b[38;2;78;78;78mX',
 143     '\x1b[38;2;102;175;135m59\x1b[38;2;78;78;78mY',
 144     '\x1b[38;2;102;175;135m5a\x1b[38;2;78;78;78mZ',
 145     '\x1b[38;2;102;175;135m5b\x1b[38;2;78;78;78m[',
 146     '\x1b[38;2;102;175;135m5c\x1b[38;2;78;78;78m\\',
 147     '\x1b[38;2;102;175;135m5d\x1b[38;2;78;78;78m]',
 148     '\x1b[38;2;102;175;135m5e\x1b[38;2;78;78;78m^',
 149     '\x1b[38;2;102;175;135m5f\x1b[38;2;78;78;78m_',
 150     '\x1b[38;2;102;175;135m60\x1b[38;2;78;78;78m`',
 151     '\x1b[38;2;102;175;135m61\x1b[38;2;78;78;78ma',
 152     '\x1b[38;2;102;175;135m62\x1b[38;2;78;78;78mb',
 153     '\x1b[38;2;102;175;135m63\x1b[38;2;78;78;78mc',
 154     '\x1b[38;2;102;175;135m64\x1b[38;2;78;78;78md',
 155     '\x1b[38;2;102;175;135m65\x1b[38;2;78;78;78me',
 156     '\x1b[38;2;102;175;135m66\x1b[38;2;78;78;78mf',
 157     '\x1b[38;2;102;175;135m67\x1b[38;2;78;78;78mg',
 158     '\x1b[38;2;102;175;135m68\x1b[38;2;78;78;78mh',
 159     '\x1b[38;2;102;175;135m69\x1b[38;2;78;78;78mi',
 160     '\x1b[38;2;102;175;135m6a\x1b[38;2;78;78;78mj',
 161     '\x1b[38;2;102;175;135m6b\x1b[38;2;78;78;78mk',
 162     '\x1b[38;2;102;175;135m6c\x1b[38;2;78;78;78ml',
 163     '\x1b[38;2;102;175;135m6d\x1b[38;2;78;78;78mm',
 164     '\x1b[38;2;102;175;135m6e\x1b[38;2;78;78;78mn',
 165     '\x1b[38;2;102;175;135m6f\x1b[38;2;78;78;78mo',
 166     '\x1b[38;2;102;175;135m70\x1b[38;2;78;78;78mp',
 167     '\x1b[38;2;102;175;135m71\x1b[38;2;78;78;78mq',
 168     '\x1b[38;2;102;175;135m72\x1b[38;2;78;78;78mr',
 169     '\x1b[38;2;102;175;135m73\x1b[38;2;78;78;78ms',
 170     '\x1b[38;2;102;175;135m74\x1b[38;2;78;78;78mt',
 171     '\x1b[38;2;102;175;135m75\x1b[38;2;78;78;78mu',
 172     '\x1b[38;2;102;175;135m76\x1b[38;2;78;78;78mv',
 173     '\x1b[38;2;102;175;135m77\x1b[38;2;78;78;78mw',
 174     '\x1b[38;2;102;175;135m78\x1b[38;2;78;78;78mx',
 175     '\x1b[38;2;102;175;135m79\x1b[38;2;78;78;78my',
 176     '\x1b[38;2;102;175;135m7a\x1b[38;2;78;78;78mz',
 177     '\x1b[38;2;102;175;135m7b\x1b[38;2;78;78;78m{',
 178     '\x1b[38;2;102;175;135m7c\x1b[38;2;78;78;78m|',
 179     '\x1b[38;2;102;175;135m7d\x1b[38;2;78;78;78m}',
 180     '\x1b[38;2;102;175;135m7e\x1b[38;2;78;78;78m~',
 181     '\x1b[38;2;148;148;148m7f ',
 182     '\x1b[38;2;148;148;148m80 ',
 183     '\x1b[38;2;148;148;148m81 ',
 184     '\x1b[38;2;148;148;148m82 ',
 185     '\x1b[38;2;148;148;148m83 ',
 186     '\x1b[38;2;148;148;148m84 ',
 187     '\x1b[38;2;148;148;148m85 ',
 188     '\x1b[38;2;148;148;148m86 ',
 189     '\x1b[38;2;148;148;148m87 ',
 190     '\x1b[38;2;148;148;148m88 ',
 191     '\x1b[38;2;148;148;148m89 ',
 192     '\x1b[38;2;148;148;148m8a ',
 193     '\x1b[38;2;148;148;148m8b ',
 194     '\x1b[38;2;148;148;148m8c ',
 195     '\x1b[38;2;148;148;148m8d ',
 196     '\x1b[38;2;148;148;148m8e ',
 197     '\x1b[38;2;148;148;148m8f ',
 198     '\x1b[38;2;148;148;148m90 ',
 199     '\x1b[38;2;148;148;148m91 ',
 200     '\x1b[38;2;148;148;148m92 ',
 201     '\x1b[38;2;148;148;148m93 ',
 202     '\x1b[38;2;148;148;148m94 ',
 203     '\x1b[38;2;148;148;148m95 ',
 204     '\x1b[38;2;148;148;148m96 ',
 205     '\x1b[38;2;148;148;148m97 ',
 206     '\x1b[38;2;148;148;148m98 ',
 207     '\x1b[38;2;148;148;148m99 ',
 208     '\x1b[38;2;148;148;148m9a ',
 209     '\x1b[38;2;148;148;148m9b ',
 210     '\x1b[38;2;148;148;148m9c ',
 211     '\x1b[38;2;148;148;148m9d ',
 212     '\x1b[38;2;148;148;148m9e ',
 213     '\x1b[38;2;148;148;148m9f ',
 214     '\x1b[38;2;148;148;148ma0 ',
 215     '\x1b[38;2;148;148;148ma1 ',
 216     '\x1b[38;2;148;148;148ma2 ',
 217     '\x1b[38;2;148;148;148ma3 ',
 218     '\x1b[38;2;148;148;148ma4 ',
 219     '\x1b[38;2;148;148;148ma5 ',
 220     '\x1b[38;2;148;148;148ma6 ',
 221     '\x1b[38;2;148;148;148ma7 ',
 222     '\x1b[38;2;148;148;148ma8 ',
 223     '\x1b[38;2;148;148;148ma9 ',
 224     '\x1b[38;2;148;148;148maa ',
 225     '\x1b[38;2;148;148;148mab ',
 226     '\x1b[38;2;148;148;148mac ',
 227     '\x1b[38;2;148;148;148mad ',
 228     '\x1b[38;2;148;148;148mae ',
 229     '\x1b[38;2;148;148;148maf ',
 230     '\x1b[38;2;148;148;148mb0 ',
 231     '\x1b[38;2;148;148;148mb1 ',
 232     '\x1b[38;2;148;148;148mb2 ',
 233     '\x1b[38;2;148;148;148mb3 ',
 234     '\x1b[38;2;148;148;148mb4 ',
 235     '\x1b[38;2;148;148;148mb5 ',
 236     '\x1b[38;2;148;148;148mb6 ',
 237     '\x1b[38;2;148;148;148mb7 ',
 238     '\x1b[38;2;148;148;148mb8 ',
 239     '\x1b[38;2;148;148;148mb9 ',
 240     '\x1b[38;2;148;148;148mba ',
 241     '\x1b[38;2;148;148;148mbb ',
 242     '\x1b[38;2;148;148;148mbc ',
 243     '\x1b[38;2;148;148;148mbd ',
 244     '\x1b[38;2;148;148;148mbe ',
 245     '\x1b[38;2;148;148;148mbf ',
 246     '\x1b[38;2;148;148;148mc0 ',
 247     '\x1b[38;2;148;148;148mc1 ',
 248     '\x1b[38;2;148;148;148mc2 ',
 249     '\x1b[38;2;148;148;148mc3 ',
 250     '\x1b[38;2;148;148;148mc4 ',
 251     '\x1b[38;2;148;148;148mc5 ',
 252     '\x1b[38;2;148;148;148mc6 ',
 253     '\x1b[38;2;148;148;148mc7 ',
 254     '\x1b[38;2;148;148;148mc8 ',
 255     '\x1b[38;2;148;148;148mc9 ',
 256     '\x1b[38;2;148;148;148mca ',
 257     '\x1b[38;2;148;148;148mcb ',
 258     '\x1b[38;2;148;148;148mcc ',
 259     '\x1b[38;2;148;148;148mcd ',
 260     '\x1b[38;2;148;148;148mce ',
 261     '\x1b[38;2;148;148;148mcf ',
 262     '\x1b[38;2;148;148;148md0 ',
 263     '\x1b[38;2;148;148;148md1 ',
 264     '\x1b[38;2;148;148;148md2 ',
 265     '\x1b[38;2;148;148;148md3 ',
 266     '\x1b[38;2;148;148;148md4 ',
 267     '\x1b[38;2;148;148;148md5 ',
 268     '\x1b[38;2;148;148;148md6 ',
 269     '\x1b[38;2;148;148;148md7 ',
 270     '\x1b[38;2;148;148;148md8 ',
 271     '\x1b[38;2;148;148;148md9 ',
 272     '\x1b[38;2;148;148;148mda ',
 273     '\x1b[38;2;148;148;148mdb ',
 274     '\x1b[38;2;148;148;148mdc ',
 275     '\x1b[38;2;148;148;148mdd ',
 276     '\x1b[38;2;148;148;148mde ',
 277     '\x1b[38;2;148;148;148mdf ',
 278     '\x1b[38;2;148;148;148me0 ',
 279     '\x1b[38;2;148;148;148me1 ',
 280     '\x1b[38;2;148;148;148me2 ',
 281     '\x1b[38;2;148;148;148me3 ',
 282     '\x1b[38;2;148;148;148me4 ',
 283     '\x1b[38;2;148;148;148me5 ',
 284     '\x1b[38;2;148;148;148me6 ',
 285     '\x1b[38;2;148;148;148me7 ',
 286     '\x1b[38;2;148;148;148me8 ',
 287     '\x1b[38;2;148;148;148me9 ',
 288     '\x1b[38;2;148;148;148mea ',
 289     '\x1b[38;2;148;148;148meb ',
 290     '\x1b[38;2;148;148;148mec ',
 291     '\x1b[38;2;148;148;148med ',
 292     '\x1b[38;2;148;148;148mee ',
 293     '\x1b[38;2;148;148;148mef ',
 294     '\x1b[38;2;148;148;148mf0 ',
 295     '\x1b[38;2;148;148;148mf1 ',
 296     '\x1b[38;2;148;148;148mf2 ',
 297     '\x1b[38;2;148;148;148mf3 ',
 298     '\x1b[38;2;148;148;148mf4 ',
 299     '\x1b[38;2;148;148;148mf5 ',
 300     '\x1b[38;2;148;148;148mf6 ',
 301     '\x1b[38;2;148;148;148mf7 ',
 302     '\x1b[38;2;148;148;148mf8 ',
 303     '\x1b[38;2;148;148;148mf9 ',
 304     '\x1b[38;2;148;148;148mfa ',
 305     '\x1b[38;2;148;148;148mfb ',
 306     '\x1b[38;2;148;148;148mfc ',
 307     '\x1b[38;2;148;148;148mfd ',
 308     '\x1b[38;2;148;148;148mfe ',
 309     '\x1b[38;5;209mff ',
 310 )
 311 
 312 # int2ascii slightly speeds up func show_ascii
 313 int2ascii = tuple(chr(i) if 32 <= i < 127 else ' ' for i in range(256))
 314 
 315 # visible noticeably speeds up func show_ascii; notice how spaces (code 32)
 316 # aren't considered visible symbols, which makes sense in func show_ascii
 317 visible = tuple(32 < i < 127 for i in range(256))
 318 
 319 
 320 def show_hex(w, src, chunk_size: int = 16) -> None:
 321     'Handle all input from the source given, emitting styled output.'
 322 
 323     # make the ruler/line-breather, which shows up every 5 hex-output lines
 324     pre = 8 * ' '
 325     pat = '           ·'
 326     pat = int(3 * chunk_size / len(pat)) * pat
 327     sep_line = f'{pre}  \x1b[38;5;245m{pat}\x1b[0m\n'
 328 
 329     # n is the current byte offset shown at the start of each display line
 330     n = 0
 331 
 332     # lines keeps track of the main output line/row count, to figure out
 333     # when to put `breather` lines
 334     lines = 0
 335 
 336     # prev remembers the previous chunk, as showing ASCII content for
 337     # 2 output-lines worth of bytes requires staying 1 step behind, so
 338     # to speak
 339     prev = src.read(chunk_size)
 340     if len(prev) == 0:
 341         return
 342 
 343     while True:
 344         chunk = src.read(chunk_size)
 345         if len(chunk) == 0:
 346             break
 347 
 348         if lines % 5 == 0 and lines > 0:
 349             w.write(sep_line)
 350         show_line(w, n, prev, chunk, chunk_size)
 351 
 352         n += len(prev)
 353         prev = chunk
 354         lines += 1
 355 
 356     # don't forget the last output line
 357     if len(prev) > 0:
 358         if lines % 5 == 0 and lines > 0:
 359             w.write(sep_line)
 360         show_line(w, n, prev, bytes(), chunk_size)
 361 
 362 
 363 def show_line(w, n: int, prev, chunk, chunk_size: int) -> None:
 364     'Help func show_hex do its job, simplifying its control flow.'
 365 
 366     # w.write(f'{n:8}  \x1b[48;5;254m')
 367     show_restyled_uint(w, n, 8)
 368     w.write('  \x1b[48;5;254m')
 369     for e in prev:
 370         w.write(bytes2styled_hex[e])
 371     w.write('\x1b[0m')
 372     show_ascii(w, prev, chunk, 3 * (chunk_size - len(prev)) + 2)
 373     w.write('\n')
 374 
 375 
 376 def show_restyled_uint(w, n: int, width: int) -> None:
 377     'Alternate styles on 3-item chunks of digits from the integer given.'
 378 
 379     digits = str(n)
 380     l = len(digits)
 381 
 382     # left-pad digits with spaces to fill the output-width given
 383     write_spaces(w, width - l)
 384 
 385     # it's quicker to just emit short-enough digit-runs verbatim
 386     if l < 4:
 387         w.write(digits)
 388         return
 389 
 390     # emit leading chunk of digits, which is the only one which
 391     # can have fewer than 3 items
 392     lead = l % 3
 393     w.write(digits[:lead])
 394 
 395     # the rest of the string now has a multiple of 3 items left
 396     start = lead
 397 
 398     # start by styling the next digit-group only if there was a
 399     # non-empty leading group at the start of the full digit-run
 400     use_style = lead > 0
 401 
 402     # alternate styles until the string is over
 403     while start < l:
 404         # the digits left are always a multiple of 3
 405         stop = start + 3
 406 
 407         if use_style:
 408             w.write('\x1b[38;5;248m')
 409             w.write(digits[start:stop])
 410             w.write('\x1b[0m')
 411         else:
 412             w.write(digits[start:stop])
 413 
 414         # switch style and advance to the next 3-digit chunk
 415         use_style = not use_style
 416         start = stop
 417 
 418 
 419 def show_ascii(w, first, second: bytes, pre: int) -> None:
 420     'Emit the ASCII side-panel for func show_hex.'
 421 
 422     # prev_vis keeps track of the previous byte's `visibility`, so spaces
 423     # are added when bytes change from non-visible-ASCII to visible-ASCII
 424     prev_vis = False
 425 
 426     is_vis = False
 427     spaces = pre
 428 
 429     # show ASCII symbols from the first `line` in the pair
 430     for e in first:
 431         is_vis = visible[e]
 432         if is_vis:
 433             if not prev_vis:
 434                 write_spaces(w, spaces)
 435                 spaces = 1
 436             w.write(int2ascii[e])
 437         prev_vis = is_vis
 438 
 439     # do the same for the second `line` in the pair
 440     for e in second:
 441         is_vis = visible[e]
 442         if is_vis:
 443             if not prev_vis:
 444                 write_spaces(w, spaces)
 445                 spaces = 1
 446             w.write(int2ascii[e])
 447         prev_vis = is_vis
 448 
 449 
 450 def write_spaces(w, n: int) -> None:
 451     'Emit the number of spaces given, minimizing `write` calls.'
 452 
 453     if n < 1:
 454         return
 455 
 456     if n < len(spaces):
 457         w.write(spaces[n])
 458         return
 459 
 460     while n >= len(spaces):
 461         w.write(spaces[-1])
 462         n -= len(spaces)
 463     w.write(spaces[n])
 464 
 465 
 466 def seems_url(s: str) -> bool:
 467     protocols = ('https://', 'http://', 'file://', 'ftp://', 'data:')
 468     return any(s.startswith(p) for p in protocols)
 469 
 470 
 471 # args is the `proper` list of arguments given to the script
 472 args = argv[1:]
 473 
 474 # a leading help-option arg means show the help message and quit
 475 if len(args) > 0 and args[0] in ('-h', '--h', '-help', '--help'):
 476     print(info.strip(), file=stderr)
 477     exit(0)
 478 
 479 # narrow-output is to fit results in 80-column mode
 480 bytes_per_line = 16
 481 if len(args) > 0 and args[0] in ('-n', '--n', '-narrow', '--narrow'):
 482     bytes_per_line = 12
 483     args = args[1:]
 484 elif len(args) > 0:
 485     # allow a leading integer argument to set exactly how many bytes per
 486     # line to show in the styled output, before the ASCII-panel contents
 487     try:
 488         # try to parse an integer number, after turning double-dashes
 489         # into single ones, which may lead to parsed negative integers
 490         n = int(args[0].lstrip('-'))
 491         # negative integers are a result of option-style leading dashes
 492         n = int(abs(n))
 493 
 494         if n > 0:
 495             # only change the width-setting if leading number isn't zero
 496             bytes_per_line = n
 497         # don't treat a leading integer as a filepath, no matter what
 498         args = args[1:]
 499     except Exception:
 500         # avoid exceptions if leading arg isn't a valid integer
 501         pass
 502 
 503 # spaces lets func write_spaces minimize `write` operations, resulting in
 504 # noticeable speed-ups when the script deals with megabytes of data
 505 spaces = tuple(i * ' ' for i in range(3 * bytes_per_line + 4))
 506 
 507 try:
 508     if args.count('-') > 1:
 509         msg = 'reading from `-` (standard input) more than once not allowed'
 510         raise ValueError(msg)
 511 
 512     if any(seems_url(e) for e in args):
 513         from urllib.request import urlopen
 514 
 515     for i, path in enumerate(args):
 516         if i > 0:
 517             stdout.write('\n')
 518             stdout.write('\n')
 519 
 520         if path == '-':
 521             stdout.write('• - (<stdin>)\n')
 522             stdout.write('\n')
 523             show_hex(stdout, stdin.buffer, bytes_per_line)
 524             continue
 525 
 526         if seems_url(path):
 527             with urlopen(path) as inp:
 528                 stdout.write(f'{path}\n')
 529                 stdout.write('\n')
 530                 show_hex(stdout, inp, bytes_per_line)
 531             continue
 532 
 533         with open(path, mode='rb', buffering=4_960) as inp:
 534             n = fstat(inp.fileno()).st_size
 535             stdout.write(f'{path}  \x1b[38;5;245m({n:,} bytes)\x1b[0m\n')
 536             stdout.write('\n')
 537             show_hex(stdout, inp, bytes_per_line)
 538 
 539     if len(args) == 0:
 540         stdout.write('• <stdin>\n')
 541         stdout.write('\n')
 542         show_hex(stdout, stdin.buffer, bytes_per_line)
 543 except BrokenPipeError:
 544     # quit quietly, instead of showing a confusing error message
 545     stderr.close()
 546 except KeyboardInterrupt:
 547     exit(2)
 548 except Exception as e:
 549     print(f'\x1b[31m{e}\x1b[0m', file=stderr)
 550     exit(1)