UTF-8 で永続化された日本語文字列を取り出して、どうしても Shift-JIS エンコーディングで出力したい

記事タイトルとURLをコピーする

件名の時点でお察しという感じですが。

あんまり、やりたくはない対応ですね。しょうがないのです。

背景

こんにちは。弊社でコーポレートエンジニアやってます橋本です。Step Functions, Lambda などが比較的なかよしです。

今、私は請求書関係のシステムを組んでいます。AWS の利用料金など、表示行数が多めになるものが存在し、それに関しては取り回しのよい csv 形式で出力してお客様に提供しよう、ということになりました。

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

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

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

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

何が問題だったのか

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

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

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

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

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

どう対処するか

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

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

参考はこのへんです。

docs.python.org

docs.python.org

docs.python.org

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

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

errors 引数の標準オプション

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

docs.python.org

デフォルトの '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) に置換

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

docs.python.org

自前のエラーハンドラを実装して、名前付きで 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 でエンコーディングのカスタムエラーハンドラを作りましょう。

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

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

補足

CP932 でエンコーディングする、という選択肢もあります。

記事の検証をやっている当時は候補として認識できていなかったのですが、本記事のようなワークアラウンドをしなくて済む可能性もありますので、アリだと思います。

私のケースに関して言えば、CP932 であればエンコーディング自体は素直にできました。ただ、CP932 は Windows の独自拡張が入った sijis の実装である (Microsoftコードページ932 - Wikipedia) との記述がありますし、ここで作成したファイルは Lambda, S3, Box, あるいはメール添付など、様々なプラットフォームの上に乗って扱われる想定があったので、MS の独自仕様に依存するコードを使うのも、ちょっとな・・・というお気持ち的な部分で消極的です。対象ファイルを扱いうるすべてのランタイム上で CP932 のサポートが万全であるとは言い切れないので、OS に依らない標準的なコードを使っておく方がチョイスとしては堅いだろう、、、と考えています

ただし、私自身は CP932 や Python における扱いなど、理解が怪しい部分が多々あります。実際には CP932 でも特に問題なく、この記事のようなことは最初からしなくてよかった可能性はありますので、あくまで参考として見ていただければ。