[Python] デコードした文字とともにバイナリデータを表示する

# はじめに

バイナリデータから日本語メッセージだけを抽出したいと思いました。
バイナリエディタを使えば簡単に出来そうですが、しっくりくるものを持っていなかったのでデータを見るための機能だけを作ってみました。

# 環境

```:OS
Windows 11 Pro
```

```:py -VV
Python 3.12.0 (tags/v3.12.0:0fb18b0, Oct  2 2023, 13:03:39) [MSC v.1935 64 bit (AMD64)]
```

# コード

```python:binary.py
import argparse
import codecs
import math

# import pdb
import re
from pathlib import Path

import Decorator as D
import Utility as U

NoBreakSpace = "\u00a0"
ReplacementCharacter = "\ufffd"  # �


def printHexList(l):
  print(len(l))
  print([f"{x:02X}" for x in l])


class Binary:
  Separator = " | "
  SeparatorNoBreakSpace = " |" + NoBreakSpace  # 結合させない為にNoBreakSpaceにする
  ReplacementCode = " "
  Replacement = "__"

  def __init__(self, inputPath, outputPath, encoding="utf-8", outputLength=16, replChar=" ", *, outputVertical=False):
    self.inputPath = Path(inputPath)
    self.outputPath = Path(outputPath)
    self.encoding = encoding
    self.outputLength = outputLength  # 表示幅 Byte
    self.errorPosition = set()

    self.rawData = b""  # バイナリデータ
    self.lineNumber = -1  # バイナリデータを表示するのに必要な行数
    self.size = -1
    self.offsetTemplate = ""
    self.string = []  # 文字データ
    self.codePoint = []  # Unicode code point
    self.replChar = replChar  # デコード出来ない場合に置き換える文字
    self.outputVertical = outputVertical  # 垂直方向に並べて出力するかどうか

    codecs.register_error("customReplace", self.customErrorHandler)
    self.checkEncoding()

  # encode/decode時にエラーが発生した場合、その場所を記録する
  def customErrorHandler(self, e):
    self.errorPosition.update(range(e.start, e.end))
    return ((e.end - e.start) * self.replChar, e.end)

  # Bomが付くと動作がおかしくなるので変更する
  def checkEncoding(self):
    utf16 = re.compile("utf[ _-]?16$", re.IGNORECASE)
    if utf16.match(self.encoding) is not None:
      self.encoding = "utf_16le"
    utf32 = re.compile("utf[ _-]?32$", re.IGNORECASE)
    if utf32.match(self.encoding) is not None:
      self.encoding = "utf_32le"
    print(f"Encoding = {self.encoding}")

  # データをメモリに読み込むが、処理に必要なだけ読み込むようにした方が良いかも
  def read(self):
    with self.inputPath.open("rb") as file:
      self.rawData = file.read()
    self.size = len(self.rawData)
    self.lineNumber = math.ceil(self.size / self.outputLength)
    offsetLength = len(f"{self.size:X}")
    self.offsetTemplate = f"{{:{offsetLength}X}}{{:2}}{Binary.Separator}"

  def getRawData(self, line):
    start = self.outputLength * line
    end = start + self.outputLength
    return self.rawData[start:end]

  # Bom付きのencodingの場合、バイト数が正しく取得できない
  def getEncodeLength(self, data):
    l = len(data.encode(self.encoding, errors="ignore"))  # decodeが成功したものをencodeするのでignoreで良い
    return l if l > 0 else 1

  # encodeした結果、3batesならば"あ____"のような文字列を作成する
  def getCharacter(self, c, length):
    string = [U.replaceControl(c, self.Replacement)]
    string += [self.Replacement for _ in range(length - 1)]
    return string

  def getCodePoint(self, c, length):
    code = [f"{ord(c):X}"]
    code += [self.ReplacementCode for _ in range(length - 1)]
    return code

  def getData(self, s):
    string = []
    code = []
    i = 0
    length = 0
    for c in s:
      if i in self.errorPosition:  # decode出来なかった文字の場合、そこを別の文字で置き換える
        string += [self.replChar]
        code += [self.ReplacementCode]
        i += 1
      else:
        length = self.getEncodeLength(c)
        string += self.getCharacter(c, length)
        code += self.getCodePoint(c, length)
        i += length
    return string, code

  # 1列のデータを行毎に折り返す。
  def wrapData(self, data):
    ret = []
    for i in range(self.lineNumber):
      start = i * self.outputLength
      end = start + self.outputLength
      ret.append(data[start:end])
    return ret

  def getString(self):
    string = self.rawData.decode(self.encoding, errors="customReplace")
    s, c = self.getData(string)
    self.string = self.wrapData(s)
    self.codePoint = self.wrapData(c)

  def getTitleHorizontal(self, *, outputString=True, outputCodePoint=True):
    offset = len(self.getOffset(0)) * " "
    addList = [f"{i:02X}" for i in range(self.outputLength)]
    address = " ".join(addList)
    ret = offset + address
    if outputCodePoint:
      ret += Binary.Separator + "     ".join(addList) + "    "
    if outputString:
      ret += Binary.Separator + address
    ret += "\n\n"
    return ret

  def getTitleVertical(self, *, outputCodePoint=True):
    offset = len(self.getOffset(0)) * " "
    addList = [f"{i:02X}" for i in range(self.outputLength)]
    address = " ".join(addList)
    ret = offset + address
    if outputCodePoint:
      ret = offset + "     ".join(addList) + "    "
    ret += "\n\n"
    return ret

  def getOffset(self, line, t=""):
    return self.offsetTemplate.format(line, t)

  def getRawString(self, line, *, fill=True):
    string = self.getRawData(line).hex().upper()
    if fill and len(string) < self.outputLength * 2:
      string += " " * (self.outputLength * 2 - len(string))
    return string

  def outputStringLine(self, line, *, vertical=False):
    string = ""
    sep = NoBreakSpace
    if vertical:
      sep = NoBreakSpace * 5
    for s in self.string[line]:
      string += s + sep
      # East Asian Widthは、結局はFontが対応しているかに係っているので信用できない
      if s != self.Replacement and U.getEaWidth(s) == 1:
        string += NoBreakSpace
    return string

  def getCodePointString(self, line):
    return " ".join([f"{x:6}" for x in self.codePoint[line]])

  def outputCodePointLine(self, line, *, fill=True):
    if not fill or line != self.lineNumber - 1:
      return self.getCodePointString(line)
    diff = self.outputLength - len(self.codePoint[line])
    tail = " " * 7 * diff
    return self.getCodePointString(line) + tail

  def outputLineHorizontal(self, line, *, outputString=True, outputCodePoint=True):
    offset = self.getOffset(line * self.outputLength)
    rawString = self.getRawString(line)
    output = offset + U.insertSeparator(rawString, 2, " ")
    if outputCodePoint:
      output += Binary.Separator
      output += self.outputCodePointLine(line)
    if outputString:
      output += Binary.SeparatorNoBreakSpace
      output += self.outputStringLine(line)
    output += "\n"
    return output

  def outputLineVertical(self, line, *, outputString=True, outputCodePoint=True):
    offsetR = self.getOffset(line * self.outputLength, " B")
    rawString = self.getRawString(line, fill=False)
    output = offsetR + U.insertSeparator(rawString, 2, " " * 5) + "\n"
    if outputCodePoint:
      offsetC = self.getOffset(line * self.outputLength, " C")
      output += offsetC + self.outputCodePointLine(line, fill=False) + "\n"
    if outputString:
      offsetD = self.getOffset(line * self.outputLength, " S")
      output += offsetD + self.outputStringLine(line, vertical=True) + "\n"
    output += "\n"
    return output

  def writeHorizontal(self, file):
    file.write(self.getTitleHorizontal())
    for i in range(self.lineNumber):
      file.write(self.outputLineHorizontal(i))

  def writeVertical(self, file):
    file.write(self.getTitleVertical())
    for i in range(self.lineNumber):
      file.write(self.outputLineVertical(i))

  def write(self):
    with self.outputPath.open("w", encoding="utf-8") as file:
      if self.outputVertical:
        self.writeVertical(file)
      else:
        self.writeHorizontal(file)

  def perform(self):
    self.read()
    self.getString()
    self.write()


def argumentParser():
  parser = argparse.ArgumentParser()
  parser.add_argument("inputPath", help="input path")
  parser.add_argument("-o", "--outputPath", default="output.txt", help="output path")
  parser.add_argument("-e", "--encoding", default="utf-8", help="encodeing")
  parser.add_argument("-l", "--length", type=int, default=16, help="length")
  parser.add_argument("-v", "--outputVertical", action="store_true", help="output vertical")
  parser.add_argument("-a", "--showArgument", action="store_true", help="show arguments.")
  return parser.parse_args()


if __name__ == "__main__":
  args = argumentParser()
  if args.showArgument:
    print(args)

  binary = Binary(
    inputPath=args.inputPath,
    outputPath=args.outputPath,
    encoding=args.encoding,
    outputLength=args.length,
    outputVertical=args.outputVertical,
  )
  binary.perform()
```

```python:Utility.py
ControlCharacters = {
  "\u0000": "\u2400",  # NUL
  "\u0001": "\u2401",  # SOH
  "\u0002": "\u2402",  # STX
  "\u0003": "\u2403",  # ETX
  "\u0004": "\u2404",  # EOT
  "\u0005": "\u2405",  # ENQ
  "\u0006": "\u2406",  # ACK
  "\u0007": "\u2407",  # BEL
  "\u0008": "\u2408",  # BS
  "\u0009": "\u2409",  # HT
  "\u000a": "\u240a",  # LF
  "\u000b": "\u240b",  # VT
  "\u000c": "\u240c",  # FF
  "\u000d": "\u240d",  # CR
  "\u000e": "\u240e",  # SO
  "\u000f": "\u240f",  # SI
  "\u0010": "\u2410",  # DLE
  "\u0011": "\u2411",  # DC1
  "\u0012": "\u2412",  # DC2
  "\u0013": "\u2413",  # DC3
  "\u0014": "\u2414",  # DC4
  "\u0015": "\u2415",  # NAK
  "\u0016": "\u2416",  # SYN
  "\u0017": "\u2417",  # ETB
  "\u0018": "\u2418",  # CAN
  "\u0019": "\u2419",  # EOM
  "\u001a": "\u241a",  # SUB
  "\u001b": "\u241b",  # ESC
  "\u001c": "\u241c",  # FS
  "\u001d": "\u241d",  # GS
  "\u001e": "\u241e",  # RS
  "\u001f": "\u241f",  # US
  # "\u0020": "\u2420",  # SP
  "\u007f": "\u2421",  # DEL
}

def replaceControl(c, repl="_"):
  if unicodedata.category(c) == "Cc":
    return ControlCharacters.get(c, repl)
  return c

def replaceControls(string, repl="_"):
  replace = functools.partial(replaceControl, repl=repl)
  return "".join(list(map(replace, string)))

# east_asian_width フォントが対応しているとは限らない
def getEaWidth(c):
  if unicodedata.east_asian_width(c) in {"W", "F", "A"}:
    return 2
  # {"N", "Na", "H"}:
  return 1

def reversedStr(string):
  return string[::-1]

def insertSeparator(data, digit=3, sep=","):
  pat = f"(.{{{digit}}}(?=.))"
  repl = f"\\1{sep}"
  return reversedStr(re.sub(pat, repl, reversedStr(data)))
```

# 実行結果

解説を後回しにして、どのような実行結果になるか示してみます。
そのために、以下で、簡単なテストデータを作成します。

```python:createTestData.py
import pickle
from pathlib import Path

test = {
  "func1": ord,
  "1": 1,
  "2": 2,
  "cat": "私は、猫が大好きです。",
  "dog": "I love dog.",
  "func2": chr,
}
path = Path("test.pkl")
with path.open("bw") as file:
  pickle.dump(test, file)
```

出来上がったデータを読み込ませて実行すると、

```
py binary.py test.pkl -o out1h.txt
```

以下のように、バイナリデータ・コードポイント・デコードデータと横に並んでテキストファイルへ出力されます。

```:out1h.txt
       00 01 02 03 04 05 06 07 08 09 0A 0B 0C 0D 0E 0F | 00     01     02     03     04     05     06     07     08     09     0A     0B     0C     0D     0E     0F     | 00 01 02 03 04 05 06 07 08 09 0A 0B 0C 0D 0E 0F

 0   | 80 04 95 7C 00 00 00 00 00 00 00 7D 94 28 8C 05 |        4             7C     0      0      0      0      0      0      0      7D            28            5      |    ␄     |  ␀  ␀  ␀  ␀  ␀  ␀  ␀  }     (     ␅  
10   | 66 75 6E 63 31 94 8C 08 62 75 69 6C 74 69 6E 73 | 66     75     6E     63     31                   8      62     75     69     6C     74     69     6E     73     | f  u  n  c  1        ␈  b  u  i  l  t  i  n  s  
20   | 94 8C 03 6F 72 64 94 93 94 8C 01 31 94 4B 01 8C |               3      6F     72     64                                 1      31            4B     1             |       ␃  o  r  d              ␁  1     K  ␁     
30   | 01 32 94 4B 02 8C 03 63 61 74 94 8C 21 E7 A7 81 | 1      32            4B     2             3      63     61     74                   21     79C1                 | ␁  2     K  ␂     ␃  c  a  t        !  私 __ __ 
40   | E3 81 AF E3 80 81 E7 8C AB E3 81 8C E5 A4 A7 E5 | 306F                 3001                 732B                 304C                 5927                 597D   | は __ __ 、 __ __ 猫 __ __ が __ __ 大 __ __ 好 
50   | A5 BD E3 81 8D E3 81 A7 E3 81 99 E3 80 82 94 8C |               304D                 3067                 3059                 3002                               | __ __ き __ __ で __ __ す __ __ 。 __ __       
60   | 03 64 6F 67 94 8C 0B 49 20 6C 6F 76 65 20 64 6F | 3      64     6F     67                   B      49     20     6C     6F     76     65     20     64     6F     | ␃  d  o  g        ␋  I     l  o  v  e     d  o  
70   | 67 2E 94 8C 05 66 75 6E 63 32 94 68 02 8C 03 63 | 67     2E                   5      66     75     6E     63     32            68     2             3      63     | g  .        ␅  f  u  n  c  2     h  ␂     ␃  c  
80   | 68 72 94 93 94 75 2E                            | 68     72                          75     2E                                                                    | h  r           u  .  

```

実行時オプションを以下のようにして実行すると、

```
py binary.py test.pkl -v -o out1v.txt
```

バイナリデータ・コードポイント・デコードデータが縦に並んでテキストファイルへ出力されます。

```:out1v.txt
       00     01     02     03     04     05     06     07     08     09     0A     0B     0C     0D     0E     0F    

 0 B | 80     04     95     7C     00     00     00     00     00     00     00     7D     94     28     8C     05
 0 C |        4             7C     0      0      0      0      0      0      0      7D            28            5     
 0 S |        ␄             |      ␀      ␀      ␀      ␀      ␀      ␀      ␀      }             (             ␅      

10 B | 66     75     6E     63     31     94     8C     08     62     75     69     6C     74     69     6E     73
10 C | 66     75     6E     63     31                   8      62     75     69     6C     74     69     6E     73    
10 S | f      u      n      c      1                    ␈      b      u      i      l      t      i      n      s      

20 B | 94     8C     03     6F     72     64     94     93     94     8C     01     31     94     4B     01     8C
20 C |               3      6F     72     64                                 1      31            4B     1            
20 S |               ␃      o      r      d                                  ␁      1             K      ␁             

30 B | 01     32     94     4B     02     8C     03     63     61     74     94     8C     21     E7     A7     81
30 C | 1      32            4B     2             3      63     61     74                   21     79C1                
30 S | ␁      2             K      ␂             ␃      c      a      t                    !      私     __     __     

40 B | E3     81     AF     E3     80     81     E7     8C     AB     E3     81     8C     E5     A4     A7     E5
40 C | 306F                 3001                 732B                 304C                 5927                 597D  
40 S | は     __     __     、     __     __     猫     __     __     が     __     __     大     __     __     好     

50 B | A5     BD     E3     81     8D     E3     81     A7     E3     81     99     E3     80     82     94     8C
50 C |               304D                 3067                 3059                 3002                              
50 S | __     __     き     __     __     で     __     __     す     __     __     。     __     __                   

60 B | 03     64     6F     67     94     8C     0B     49     20     6C     6F     76     65     20     64     6F
60 C | 3      64     6F     67                   B      49     20     6C     6F     76     65     20     64     6F    
60 S | ␃      d      o      g                    ␋      I             l      o      v      e             d      o      

70 B | 67     2E     94     8C     05     66     75     6E     63     32     94     68     02     8C     03     63
70 C | 67     2E                   5      66     75     6E     63     32            68     2             3      63    
70 S | g      .                    ␅      f      u      n      c      2             h      ␂             ␃      c      

80 B | 68     72     94     93     94     75     2E
80 C | 68     72                          75     2E    
80 S | h      r                           u      .      

```

# 解説

## データの読み込み

`self.read()`でファイルからバイナリデータを読み込みます。
データを`print()`で表示すると以下になります。

```:バイナリデータ
b'\x80\x04\x95|\x00\x00\x00\x00\x00\x00\x00}\x94(\x8c\x05func1\x94\x8c\x08builtins\x94\x8c\x03ord\x94\x93\x94\x8c\x011\x94K\x01\x8c\x012\x94K\x02\x8c\x03cat\x94\x8c!\xe7\xa7\x81\xe3\x81\xaf\xe3\x80\x81\xe7\x8c\xab\xe3\x81\x8c\xe5\xa4\xa7\xe5\xa5\xbd\xe3\x81\x8d\xe3\x81\xa7\xe3\x81\x99\xe3\x80\x82\x94\x8c\x03dog\x94\x8c\x0bI love dog.\x94\x8c\x05func2\x94h\x02\x8c\x03chr\x94\x93\x94u.'
```

16進数で表示すると以下になります。

```:バイナリデータ(16進数表記)
80 04 95 7C 00 00 00 00 00 00 00 7D 94 28 8C 05 66 75 6E 63 31 94 8C 08 62 75 69 6C 74 69 6E 73 94 8C 03 6F 72 64 94 93 94 8C 01 31 94 4B 01 8C 01 32 94 4B 02 8C 03 63 61 74 94 8C 21 E7 A7 81 E3 81 AF E3 80 81 E7 8C AB E3 81 8C E5 A4 A7 E5 A5 BD E3 81 8D E3 81 A7 E3 81 99 E3 80 82 94 8C 03 64 6F 67 94 8C 0B 49 20 6C 6F 76 65 20 64 6F 67 2E 94 8C 05 66 75 6E 63 32 94 68 02 8C 03 63 68 72 94 93 94 75 2E
```

バイナリデータは、容量が大きいものも多くメモリに全て読み込むのは避けたいところです。
必要な範囲だけ読み込むことも検討しましたが、デコードするときにデータが区切られた場所によっては、正しくデコードされない事があるので先頭から全て読み込んでいます。
例えば、`b'\xe7\x8c\xab'`を読み込むと"猫"にデコードされます。
これが`b'\x8c\xab'`のように区切られてしまうとデコードエラーになってしまいます。

データを全て保持する事に加えて、処理の過程でファイルの容量の数倍のデータを保持するので余りに大きなファイルを扱うのには向いていません。

## デコード

次に、`self.getSrgint()`でバイナリデータからコードポイントとデコードデータを作成します。
ここでは、まずはバイナリデータをデコードします。
デコードする時に、以下のようなカスタムエラーハンドラーを使用しています。
これは、

- 変換できなかった文字の位置を記録する
- 変換できなかった文字を指定した文字(空白)で置き換える

ために使用します。

```python:エラーハンドラー
codecs.register_error("customReplace", self.customErrorHandler)
  
def customErrorHandler(self, e):
  self.errorPosition.update(range(e.start, e.end))
  return ((e.end - e.start) * self.replChar, e.end)

string = self.rawData.decode(self.encoding, errors="customReplace")
```

デコードした文字列は、以下のようなものになります。
実際には、制御文字が含まれているので表示が崩れてしまいます。

```:デコードデータ
  |} ( func1 builtins  ord    1 K 2 K cat  !私は、猫が大好きです。  dog
I love dog.  func2 h chr   u.
```

デコード出来なかった文字は、空白に置き換わっています。
デコード文字列だけを見た場合、

- デコード結果で空白が得られた
- エラーで空白に置き換わった

が区別出来ません。
これを避けるためにカスタムエラーハンドラーでエラーが起こった場所(インデックス)を記録しています。

```:デコードエラーの場所(16進表記)
29
['00', '02', '82', '83', '84', '0C', '0E', '15', '16', '20', '21', '26', '27', '28', '29', '2C', '2F', '32', '35', '3A', '3B', '5E', '5F', '64', '65', '72', '73', '7A', '7D']
```

## 位置合わせ

`self.getData(string)`でバイナリデータ・デコードデータ・コードポイントの位置合わせを行います。
これは、デコードデータを1文字ずつ読み込んで処理を行います。
説明のためにデコードデータを`a 猫 b 犬`とします。

始めに、`a`を取り出します。
`a`は、エンコードデータが`\x61`で1バイト、コードポイントが`U+0061`となります。
エンコード時のバイト数は、非効率ですが、デコードデータをエンコードし直して求めています。
1バイトでかつデコードエラーも出ない文字なので、`["a"]`と`["62"]`を対応するリストにそのまま加算します。
そして、バイナリデータの位置を表すインデックスを1バイト分だけ進めます。

次の` `が、デコードエラーによって置き換わった空白とすると、エラーの位置が記録されているので置き換え文字`[" "]`と`[""]`をそれぞれリストに保存します。
バイナリデータのインデックスを1バイト分だけ進めます。

`猫`は、エンコードデータが`\xE7\x8C\xAB`で3バイト、コードポイントが`U+732B`となります。
2バイト以上の場合、`["猫", "__", "__"]`、`["732B", "", ""]`のような文字列をリストに保存します。
3バイトのデータなので、バイナリデータのインデックスを3バイト分だけ進めます。

上記のテストデータで対応付けを行った場合は、以下のようなデータが得られます。

```:デコードデータ
9
0 : 16 : [' ', '␄', ' ', '|', '␀', '␀', '␀', '␀', '␀', '␀', '␀', '}', ' ', '(', ' ', '␅']
1 : 16 : ['f', 'u', 'n', 'c', '1', ' ', ' ', '␈', 'b', 'u', 'i', 'l', 't', 'i', 'n', 's']
2 : 16 : [' ', ' ', '␃', 'o', 'r', 'd', ' ', ' ', ' ', ' ', '␁', '1', ' ', 'K', '␁', ' ']
3 : 16 : ['␁', '2', ' ', 'K', '␂', ' ', '␃', 'c', 'a', 't', ' ', ' ', '!', '私', '__', '__']
4 : 16 : ['は', '__', '__', '、', '__', '__', '猫', '__', '__', 'が', '__', '__', '大', '__', '__', '好']
5 : 16 : ['__', '__', 'き', '__', '__', 'で', '__', '__', 'す', '__', '__', '。', '__', '__', ' ', ' ']
6 : 16 : ['␃', 'd', 'o', 'g', ' ', ' ', '␋', 'I', ' ', 'l', 'o', 'v', 'e', ' ', 'd', 'o']
7 : 16 : ['g', '.', ' ', ' ', '␅', 'f', 'u', 'n', 'c', '2', ' ', 'h', '␂', ' ', '␃', 'c']
8 :  7 : ['h', 'r', ' ', ' ', ' ', 'u', '.']
```

```:コードポイント
9
0 : 16 : ['', '4', '', '7C', '0', '0', '0', '0', '0', '0', '0', '7D', '', '28', '', '5']
1 : 16 : ['66', '75', '6E', '63', '31', '', '', '8', '62', '75', '69', '6C', '74', '69', '6E', '73']
2 : 16 : ['', '', '3', '6F', '72', '64', '', '', '', '', '1', '31', '', '4B', '1', '']
3 : 16 : ['1', '32', '', '4B', '2', '', '3', '63', '61', '74', '', '', '21', '79C1', '', '']
4 : 16 : ['306F', '', '', '3001', '', '', '732B', '', '', '304C', '', '', '5927', '', '', '597D']
5 : 16 : ['', '', '304D', '', '', '3067', '', '', '3059', '', '', '3002', '', '', '', '']
6 : 16 : ['3', '64', '6F', '67', '', '', 'B', '49', '20', '6C', '6F', '76', '65', '20', '64', '6F']
7 : 16 : ['67', '2E', '', '', '5', '66', '75', '6E', '63', '32', '', '68', '2', '', '3', '63']
8 :  7 : ['68', '72', '', '', '', '75', '2E']
```

## ファイルへの書き込み

`self.write()`でファイルへデータを書き込んでいます。
大部分は位置を合わせて書き込んでいるだけなので、解説の必要も無いかと思います。
ただ、位置合わせのために考慮すべき事が2点あるのでそれだけは解説します。

### East Asian Width

表示位置を合わせるために、全角か半角かを判定する必要があります。
これの判定には、Unicodeの`East_Asian_Width 参考特性`を使用しています。
仕様上では、これを見れば全角か半角かが分かります。
しかし、実際の所、フォントがこの情報を守って作成されているかに係っていますのであまり参考になる情報ではありません。

```python:East Asian Width
def getEaWidth(c):
  if unicodedata.east_asian_width(c) in {"W", "F", "A"}:
    return 2
  return 1
```

### 結合文字を結合させない

Unicodeには、先行する文字と組み合わせて字形を変化させる結合文字が存在します。
このような文字を単独で表示させたい場合は、先行する文字を"No-break space"(`U+00A0`)や、"Zero width non-joiner"(`U+200C`)とする事で結合させないように出来ます。
(もっとも、ちょっとうろ覚えですが、それでも結合してしまう文字があった気もしますが。)
デコードデータに関しては、文字の間を"No-break space"としています。

```python:No Break Space
  def outputStringLine(self, line, *, vertical=False):
    string = ""
    sep = NoBreakSpace
    if vertical:
      sep = NoBreakSpace * 5
    for s in self.string[line]:
      string += s + sep
      if s != self.Replacement and U.getEaWidth(s) == 1:
        string += NoBreakSpace
    return string
```

# エンコード指定の制限

デコードデータをエンコードし直してバイト数を求めている関係上、エンコード時にBom(Byte Order Mark)を付けるエンコーディングを指定すると想定通りに動きません。
そのため、簡単ですがエンコーディングを変更する処理を入れています。

```python
  def checkEncoding(self):
    utf8 = re.compile("utf[ _-]?8[ _-]sig$", re.IGNORECASE)
    if utf8.match(self.encoding) is not None:
      self.encoding = "utf_8"
      return
    utf16 = re.compile("utf[ _-]?16$", re.IGNORECASE)
    if utf16.match(self.encoding) is not None:
      self.encoding = "utf_16le"
      return
    utf32 = re.compile("utf[ _-]?32$", re.IGNORECASE)
    if utf32.match(self.encoding) is not None:
      self.encoding = "utf_32le"
      return
```

---

# 別解(と言うか元々の目的を達成したもの)

バイナリデータから日本語データだけを抽出したい場合は、以下のコードで十分です。
アルファベット等も抽出したい場合は、正規表現を工夫すれば対応できます。
本当に必要なデータだけを取り出すのは大変だとは思いますが。

```python:extract.py
import argparse
from pathlib import Path

import regex

def argumentParser():
  parser = argparse.ArgumentParser()
  parser.add_argument("inputPath", type=Path, help="input path")
  parser.add_argument("-e", "--encoding", default="utf_8", help="encoding")
  parser.add_argument("-o", "--outputPath", default="", help="output path")
  parser.add_argument("-a", "--showArgument", action="store_true", help="show arguments.")
  return parser.parse_args()

if __name__ == "__main__":
  args = argumentParser()
  if args.showArgument:
    print(args)

  output = args.outputPath
  if args.outputPath is None:
    output = Path(f"extract_{args.inputPath.stem}.txt")

  data = b""
  with args.inputPath.open("rb") as file:
    data = file.read()

  string = data.decode(args.encoding, "ignore")
  string = regex.sub(
    r"[^\p{scx=Han}\p{scx=Hiragana}\p{scx=Katakana}]",
    "",
    string,
  )

  with output.open("w", encoding="utf_8") as file:
    file.write(string)
```

上記で示したテストデータに対して実行した結果は、以下になります。

```:実行結果 extract_test.txt
私は、猫が大好きです。
```

---
# フォントについて

余談ですが、使用しているフォントによっては、表示がずれてしまいます。
こればっかりはどうしようもありません。
なるべく対応する文字が多いフォントを見つけるか、表示幅が同じフォントを複数使用するなどするしかありません。
あまり多くのフォントを試していませんが、筆者は、使い勝手が良いので`Migu 1M`を使用しています。

https://itouhiro.github.io/mixfont-mplus-ipa/migu/

自分の記事ですが、フォントの情報を得る方法も示しておきます。

https://qiita.com/BlueSilverCat/items/af64d1b2768fc98fc422

横幅が1の文字があれば無理やり位置を合わせる事も出来そうです。
もっとも、そんなことをしても面倒なだけで実用的でないと思います。