[python] やむを得ない事情で utf-8 の文字集合からなる日本語を sjis エンコードしなければならない場合のワークアラウンド

7 min read読了の目安(約6600字 1

これは一体誰が喜んぶだという気もしますが、仕事でこのへんの検証をしたので残しておきます。

プロダクトのコードベースには commit したけど実戦投入はしたくない...

背景

今、私は請求書関係のシステムを組んでいます。事業によっては行数が多めになる請求種別があり、csv フォーマットを付帯して顧客に提供する必要が出ました。

明細の元データとなる情報は自社の CRM に入っています。 CRM の文字コードは utf-8 であり、当然(utf-8 で表現可能な)任意の日本語文字が含まれる可能性があります。例えば取引先の社名とかですね。

一方で、提供したい csv はというと Windows/Mac どちらの環境でも開かれる可能性があり、おそらく大多数はビュアーとして Excel を使うであろうことが想定されました。

このとき、csv フォーマットを Windows/Mac の Excel でそれぞれ開いて無事だったエンコーディングは sjis でした。よって csv フォーマットのエンコーディングは sjis で行くのが無難と判断しました。

厳密に OS や Office のバージョンを指定した、きっちりした検証ではないですが、参考まで以下のような組み合わせを試しました。(あくまで参考まで)

(1) windows
utf-8 … 文字化け
sjis … OK
utf16 … 文字化け
utf16be … 文字化け
utf16le … 文字化けはしていないが、カンマが列区切りとして認識されていない

(2) mac
utf-8 … OK
sjis … OK
utf16 … 文字化け
utf16be … 文字化け
utf16le … 文字化けはしていないが、カンマが列区切りとして認識されていない

※ ちなみに Mac の表計算アプリ、 Numbers は概ねすべてのエンコーディングで表示に支障が出ませんでした。優秀ですね。Mac 環境の相手なら、とりあえず Numbers で開いてみてくださいと案内できそうです

※ freee さんの請求書サービス "Bill One" でも、csv の出力に sjis を採用している場所があるみたいです。freee ヘルプセンター - CSVファイルが文字化けした場合の対処方法

何が問題だったのか

CRM (utf-8) 上には存在し、かつ sjis に変換できない文字が存在したことです。

例えば、"①" は sjis で利用不可能です。商談の管理名とか、明細品目名あたりで出現しそうな文字ですね。 とか ® とかもそうです。

Python の文字列エンコーディングだと str.encode()codecs.encode() を使いますひ、ファイル出力を伴う処理であれば open() で encoding 指定すると思います。

上記のような文字が含まれるテキストが入力であった場合、sjis エンコーディング指定でのエンコードは失敗し UnicodeEncodeError 例外が発生します。

しかし、やんごとない事情で「そこをなんとか」と言いたいわけです。

どう対処するか

方針自体は簡単で、エンコーディングできない文字を見つけたら、別の(ターゲットの文字コードでエンコーディング可能な)文字に置き換えて処理を継続する ようにすればOKです。

これを Python でどうすれば実現できるか?ということですが、str.encode()codecs.encode()errors という引数があるのでそれを指定してあげると良いです。

参考はこのへんです。

https://docs.python.org/ja/3.8/library/stdtypes.html#str.encode

https://docs.python.org/ja/3.8/library/codecs.html#codecs.encode

https://docs.python.org/ja/3.8/library/functions.html#open

見ての通り errors という引数が存在し、デフォルトは strict という値であることがわかります。

UnicodeEncodeError を送出するのは errros='strict' のときの挙動です。ここを別の引数に置き換えることで、エンコード不可能な文字のハンドリングをカスタマイズすることができます。

errors 引数の標準オプション

取りうる値の一覧は以下のドキュメントに記載があります。

https://docs.python.org/ja/3.8/library/codecs.html#error-handlers

デフォルトの 'strict' 以外は、エンコード不可能な文字を何かしら別の文字で置き換えることによってエンコーディング処理を継続しようとします。

ignorereplace あたりは(人間の目からすると情報欠損が発生するものの)文字化けを回避しつつ、無難な見た目になります。

REPR で簡単に検証できるので、スニペットを貼っておきます。

# 文字 "①" は sjis エンコーディング不可能
>>> s = '取引先①, test'
>>> s.encode('sjis', errors='ignore').decode('sjis')
'取引先, test'

>>> s.encode('sjis', errors='replace').decode('sjis')
'取引先?, test'

>>> s.encode('sjis', errors='backslashreplace').decode('sjis')
'取引先\\u2460, test'

ここでポイントになるのが「置換の規則は固定である」ことです。

例えば、 ほげほげ案件① といった文字列が入力された場合。

こういう文字列が想定される場合、できれば「1番目の案件である」っていう情報量を、 人間にも読みやすいテキスト で維持しておきたいと思いませんか。例えば、 だったら (1) に置換、といった風にできたらいいんじゃね、と当時の私は思いました。

標準の errors が取りうる値の中では、残念ながら(人間が読む想定の) csv フォーマットに適した選択肢がありませんでした。

※ もちろん、出力先の要件が異なればこの限りではありません。要件に応じてオプションを確認してみてください。

ではどうするのか。何かしらのマッピング情報を自前で持っておいて、そのマッピングに従って置換できると嬉しいです。errors 引数に取りうる値は自前で拡張することも可能で、その実装方法を次で示します。

codecs.register_error でエンコーディング時のカスタムエラーハンドラを実装する

以下のようなことを実現したい場合は、 codecs の register_error にカスタムのエラーハンドラを登録することで要件が達成できます。

だったら (1) に置換

ドキュメントはこちらです。

https://docs.python.org/ja/3.8/library/codecs.html#codecs.register_error

自前のエラーハンドラを実装して、名前付きで register してあげれば、その名前を errors で使えるようになります。 codecs.encodestr.encode などを呼び出す場合に、その名前を errors 引数に指定してあげます。

とりあえず、同様に他の数字も同じようなルールで変換して、ついでに (株) に変換してみることにしましょう。実装例はこちらです。

# main.py
_error_handler_registered = False
_str_sjis_mapping = {
    '①': '(1)',
    '②': '(2)',
    '③': '(3)',
    '④': '(4)',
    '⑤': '(5)',
    '⑥': '(6)',
    '⑦': '(7)',
    '⑧': '(8)',
    '⑨': '(9)',
    '㈱': '(株)',
}


def _error_handler(e: UnicodeError):
    # print(e.args)
    (encoding, text, i, j, msg) = e.args
    return (
        _str_sjis_mapping.get(text[i], ''),
        j
    )


def str_to_sjis(s: str) -> bytes:
    """Unicode(str) を sjis に変換する

    see also: https://docs.python.org/ja/3.8/library/codecs.html#codecs.register_error
    """
    global _error_handler_registered
    if not _error_handler_registered:
        codecs.register_error('my_custom_handler', _error_handler)
        _error_handler_registered = True

    return s.encode(encoding='sjis', errors='my_custom_handler')
# unitest
from unittest import TestCase
from main import str_to_sjis

class TestCustomErrorHandler(TestCase):
    def test_str_to_sjis(self):
        testcases = [
            ('取引先①', '取引先(1)'),
            ('取引先㈱', '取引先(株)'),
        ]

        for case in testcases:
            with self.subTest(input=case[0], expect=case[1]):
                self.assertEqual(str_to_sjis(case[0]).decode('sjis'), case[1])

エラーハンドラとして登録する関数 _error_handler が、エンコーディングのエラーが発生した場合に呼び出されます。ここで自前の振る舞いを定義することで無理くり sjis への変換を行えるようになります。

このハンドラは引数に UnicodeError を受け取ります。ハンドラ関数のあるべき仕様はきっちりドキュメント化されていませんが、 register_error のドキュメントに文章ベースで說明がありますのでそれを参考に実装しました。

For encoding, error_handler will be called with a UnicodeEncodeError instance, which contains information about the location of the error. The error handler must either raise this or a different exception, or return a tuple with a replacement for the unencodable part of the input and a position where encoding should continue. The replacement may be either str or bytes. If the replacement is bytes, the encoder will simply copy them into the output buffer. If the replacement is a string, the encoder will encode the replacement. Encoding continues on original input at the specified position. Negative position values will be treated as being relative to the end of the input string. If the resulting position is out of bound an IndexError will be raised.

おおよそ、

  • エラーハンドラは UnicodeError を受け取るよ
  • エラーハンドラの戻り値は、「置換先の文字」と「何文字目から後続のエンコーディングを再開するか」の2つの情報からなるタプルを返してね
  • UnicodeError オブジェクトの中にエラーの情報があるよ
  • 戻り値の「置換先」は str でも byte でもいいよ

などのようなことを言っています。

エラーハンドラの引数の扱い方を調べるために print を仕込みつつ調査してみたら、最終的に上記のような実装に行き着きました。文字のマッピングを保持する dict を持っておいて、変換できるならそのマッピングから引く。引けなければ、デフォルト値(上記の例では空文字)に Fall back するようにしました。

戻り値の型で、置換先の文字は str と byte どちらでも良いと言っています。ここは地味に曲者で、str を返すとその置換先の文字に対して再びエンコーディングを適用しようとします。よって、置換先の文字を間違えると無限ループが発生してしまいます。対応先の文字コードが決まっているなら byte 表現を返すようにしておいた方が無難です。

まとめ

どうしても 人間にとっての可読性を維持しながら utf-8 -> sjis のエンコーディングをしたくなったら、エンコーディング時の error 引数を変えるか、あるいは(もうどうにもならなかったら) codecs.register_error でエンコーディングのカスタムエラーハンドラを作りましょう。

言うまでもないことですが、最後のやつは負債の匂いがしますね。苦情が来るたびにマッピングの定義をメンテするなんで想像したくないですよね...。

奥の手として把握だけしておいて、実際に実装しなくて済むように調整するのがいちばんです。