pythonちょい嵌り-'shift_jis' codec can't encode character '\uff5e' in position 1: illegal multibyte sequence

# はじめに

[Django2で動的にformを作る2-MultipleChoiceFieldに対応させる-](https://qiita.com/imp555sti/items/ed3a577e8138918718a4)で作成したものを加工して
CSV形式でZIPファイルに出力するものを作りました。

クライアント端末であるWindows10から入力した「～」文字が
CSVで吐き出す際にUnicodeからShift-JISにマッピングできず困惑しました。
出力されるエラーはこんな感じ
**'shift_jis' codec can't encode character '\uff5e' in position 1: illegal multibyte sequence**

これも嵌りポイントだと思うので記事にして行きます(^^♪

# お試しコード
動的にformを作る2のソースにcsv,zip出力を組み込んだものとなります。

```python:urls.py
from django.urls import path

from . import views

urlpatterns = [
    path('', views.dynamic, name='index'),
]
```

```python:forms.py
from django import forms

class EnqForm(forms.Form):
    pass
```

```python:views.py
from django.shortcuts import render
from .forms import EnqForm
from django import forms
from django.http import HttpResponse
import io
import os
import csv, zipfile
import tempfile

# Create your views here.
def dynamic(request):
    context = {}
    content = {}
    form_item = {}
    qs = []

    # define enquete fields
    qs.append( {'title':'title1', 'description': 'note1', 'type': 'text' , 'required': False} )
    qs.append( {'title':'title2', 'description': 'note2', 'type': 'text' , 'required': True} )
    qs.append( {'title':'title3', 'description': 'note3', 'type': 'radio' , 'required': False} )
    qs.append( {'title':'title4', 'description': 'note4', 'type': 'multi' , 'required': True} )
    qs.append( {'title':'title5', 'description': 'note5', 'type': 'radio' , 'required': True} )

    # create enquete form objects
    no = 0
    for q in qs:
        if q['type'] == 'text':
            form_item.update( { ('ans%d' % no): forms.CharField(label=q['title'], label_suffix=q['description'], required=q['required'], max_length=256) } )
        elif q['type'] == 'radio':
            form_item.update( { ('ans%d' % no): forms.ChoiceField(label=q['title'], label_suffix=q['description'], required=q['required'], choices=(('a','a'),('b','b'),('c','c'),('d','d')), widget=forms.RadioSelect) } )
        elif q['type'] == 'multi':
            form_item.update( { ('ans%d' % no): forms.MultipleChoiceField(label=q['title'], label_suffix=q['description'], required=q['required'], choices=(('a','a'),('b','b'),('c','c'),('d','d')), widget=forms.CheckboxSelectMultiple) } )
        no += 1

    # create dynamic form
    DynamicEnqForm = type('DynamicEnqForm', (EnqForm,), form_item )

    # get enquete answers
    if request.method == 'POST':
        formset = DynamicEnqForm(request.POST or None)
        if formset.is_valid():
            for key in formset.cleaned_data:
                if key != 'csrfmiddlewaretoken' and key != 'method':
                    content[key] = formset.cleaned_data[key]
            if 'method' in request.POST and request.POST['method'] == 'regist':
                # regist

                fp = tempfile.NamedTemporaryFile(mode='w', newline='', encoding='shift_jis', delete=False)
                fname = fp.name
                header=[]
                for key in content:
                    header.append(key)
                writer = csv.DictWriter(fp, fieldnames=header)
                writer.writeheader()
                writer.writerow(content)
                fp.close()

                fp = open(fname, 'r')

                zip_io = io.BytesIO()
                with zipfile.ZipFile(zip_io, mode='w', compression=zipfile.ZIP_DEFLATED) as backup_zip:
                    backup_zip.writestr('CSVファイル.csv', fp.read())
                response = HttpResponse(zip_io.getvalue(), content_type='application/x-zip-compressed')
                response['Content-Disposition'] = 'attachment; filename=%s' % 'your_zipfilename' + ".zip"
                response['Content-Length'] = zip_io.tell()
                fp.close()
                os.unlink(fname)
                return response

            else:
                # confirm
                context['method'] = 'confirm'
                context['title'] = []
                f_items = form_item['declared_fields']
                for item in f_items:
                    if type(content[item]) == type([]):
                        context['title'].append({'title': f_items[item].label, 'description': f_items[item].label_suffix, 'answer': ','.join(content[item])})
                    else:
                        context['title'].append({'title': f_items[item].label, 'description': f_items[item].label_suffix, 'answer': content[item]})

    # draw enquete form
    DynamicForm = DynamicEnqForm(content)
    context['enq_form'] = DynamicForm
    return render(request, "dynamic.html", context)
```

```html:dynamic.html
<form action="" method="POST">
  {% csrf_token %}
  {% if method == 'confirm' %}
  <div>
    <div>
      {% for item in title %}
      <p>{{ item.title }}</p>
      <p>{{ item.description}}</p>
      <p>{{ item.answer}}</p>
      {% endfor %}
      {% for field in enq_form %}
      {{ field.as_hidden }}
      {% endfor %}
      <input type="hidden" name="method" value="regist" />
    </div>
  </div>
  {% else %}
  {% for field in enq_form.visible_fields %}
  <div>
    <div>
      <label for="{{ field.id_for_label }}">{{ field.label }}</label>
      <p>{{ field.field.label_suffix }}</p>
      {{ field }}
    </div>
  </div>
  {% endfor %}
  {% endif %}
  <input type="submit">
</form>
```

今回の主役はここのコード

```python:views.py
            for key in formset.cleaned_data:
                if key != 'csrfmiddlewaretoken' and key != 'method':
                    content[key] = formset.cleaned_data[key]
            if 'method' in request.POST and request.POST['method'] == 'regist':
                # regist

                fp = tempfile.NamedTemporaryFile(mode='w', newline='', encoding='shift_jis', delete=False)
                fname = fp.name
                header=[]
                for key in content:
                    header.append(key)
                writer = csv.DictWriter(fp, fieldnames=header)
                writer.writeheader()
                writer.writerow(content)
                fp.close()

                fp = open(fname, 'r')

                zip_io = io.BytesIO()
                with zipfile.ZipFile(zip_io, mode='w', compression=zipfile.ZIP_DEFLATED) as backup_zip:
                    backup_zip.writestr('CSVファイル.csv', fp.read())
                response = HttpResponse(zip_io.getvalue(), content_type='application/x-zip-compressed')
                response['Content-Disposition'] = 'attachment; filename=%s' % 'your_zipfilename' + ".zip"
                response['Content-Length'] = zip_io.tell()
                fp.close()
                os.unlink(fname)
                return response
```

# 実際に試してみる(成功パターン)
クライアントはWindows10でブラウザはChromeを使ってます。
まずは成功パターンである無難な文字列の入力
デスクトップが汚かったのでc:\TEMPに保存しましたが、Excelが真っ白だったのでデスクトップに保存するようにしたら問題なく表示されました。

![10.png](https://qiita-image-store.s3.amazonaws.com/0/324255/67ca8321-c6ec-bae5-c907-9fec0ac627f6.png)
![11.png](https://qiita-image-store.s3.amazonaws.com/0/324255/5537bf29-1b43-4508-e7e8-0580f9af8c57.png)
![12.png](https://qiita-image-store.s3.amazonaws.com/0/324255/1fc03bc8-44a7-fcae-f5ad-d51489ca8ae4.png)
![13.png](https://qiita-image-store.s3.amazonaws.com/0/324255/17e5c3dd-0e1d-7fcf-14dc-c12757a875cd.png)
![14.png](https://qiita-image-store.s3.amazonaws.com/0/324255/8e7ac5dc-c6f8-85ba-12ef-e065002b20f3.png)
![15.png](https://qiita-image-store.s3.amazonaws.com/0/324255/4dca0dd1-030f-6c00-bc1b-448a752bfb37.png)


# 実際に試してみる(失敗パターン)
クライアントはWindows10でブラウザはChromeを使ってます。
「～」をフォームに入力します。

![20.png](https://qiita-image-store.s3.amazonaws.com/0/324255/63526a2d-1cca-c209-8994-3c3e4ffa8962.png)
![21.png](https://qiita-image-store.s3.amazonaws.com/0/324255/3f635ef7-cb41-0dbc-56f8-b7978bbfa657.png)
![22.png](https://qiita-image-store.s3.amazonaws.com/0/324255/c9f3addc-8361-bbf6-a1dc-eae40d208b29.png)

とまぁこんな感じで怒られます"(-""-)"

# 原因は…
Windowsの文字コードはShift-JISではなくShift-JISの亜種であるCP932
「～」はPython上でUnicodeに変換されてますがそのコードはU+FF5E
Windows上のIMEパッドで確認すると…Shift-JIS(CP932)では0x8160
> Unicodeには、「波型」と「全角ティルダ」という二つの異なる「〜」が存在します。一般的には「波型」であるu'\u301c'が使われます。Shift_JISの「〜」'\x81\x60'はUnicodeの「波型」にマップされています。

詳しくはこちらの記事を参考にしてください。
[Unicode波型問題 CP932とShift_JISは同じエンコーディングではない](http://tanakahisateru.hatenablog.jp/entry/20080728/1217216409)

![30.png](https://qiita-image-store.s3.amazonaws.com/0/324255/eda1c219-2457-069d-2580-336ce4b6f6ee.png)
![31.png](https://qiita-image-store.s3.amazonaws.com/0/324255/82b69266-8f6a-71cf-5f7c-7e917e1538a3.png)

# 文字化け対策コード
文字化けを防ぐため、
- クライアントから送信されてくる値についてはCP932でエンコードし、Shift_JISでデコードする。
- ファイルはShift_JISで作成
- ZIPに読み込ませる際はバイナリーモードで読み込む
を対策し無事にCSVが文字化けせずに出力されました。

![40.png](https://qiita-image-store.s3.amazonaws.com/0/324255/fb868e3b-2423-bfe5-08ac-bcdfce75e802.png)

```python:views.py
from django.shortcuts import render
from .forms import EnqForm
from django import forms
from django.http import HttpResponse
import io
import os
import csv, zipfile
import tempfile

# Create your views here.
def dynamic(request):
    context = {}
    content = {}
    form_item = {}
    qs = []

    # define enquete fields
    qs.append( {'title':'title1', 'description': 'note1', 'type': 'text' , 'required': False} )
    qs.append( {'title':'title2', 'description': 'note2', 'type': 'text' , 'required': True} )
    qs.append( {'title':'title3', 'description': 'note3', 'type': 'radio' , 'required': False} )
    qs.append( {'title':'title4', 'description': 'note4', 'type': 'multi' , 'required': True} )
    qs.append( {'title':'title5', 'description': 'note5', 'type': 'radio' , 'required': True} )

    # create enquete form objects
    no = 0
    for q in qs:
        if q['type'] == 'text':
            form_item.update( { ('ans%d' % no): forms.CharField(label=q['title'], label_suffix=q['description'], required=q['required'], max_length=256) } )
        elif q['type'] == 'radio':
            form_item.update( { ('ans%d' % no): forms.ChoiceField(label=q['title'], label_suffix=q['description'], required=q['required'], choices=(('a','a'),('b','b'),('c','c'),('d','d')), widget=forms.RadioSelect) } )
        elif q['type'] == 'multi':
            form_item.update( { ('ans%d' % no): forms.MultipleChoiceField(label=q['title'], label_suffix=q['description'], required=q['required'], choices=(('a','a'),('b','b'),('c','c'),('d','d')), widget=forms.CheckboxSelectMultiple) } )
        no += 1

    # create dynamic form
    DynamicEnqForm = type('DynamicEnqForm', (EnqForm,), form_item )

    # get enquete answers
    if request.method == 'POST':
        formset = DynamicEnqForm(request.POST or None)
        if formset.is_valid():
            for key in formset.cleaned_data:
                if key != 'csrfmiddlewaretoken' and key != 'method':
                    if type(formset.cleaned_data[key]) != type([]):
                        content[key] = formset.cleaned_data[key].encode('cp932').decode('shift_jis')
                    else:
                        items = formset.cleaned_data[key]
                        buf = []
                        for item in items:
                            buf.append( item.encode('cp932').decode('shift_jis') )
                        content[key] = buf

            if 'method' in request.POST and request.POST['method'] == 'regist':
                # regist

                fp = tempfile.NamedTemporaryFile(mode='w', newline='', encoding='shift_jis', delete=False)
                fname = fp.name
                header=[]
                for key in content:
                    header.append(key)
                writer = csv.DictWriter(fp, fieldnames=header)
                writer.writeheader()
                writer.writerow(content)
                fp.close()

                fp = open(fname, 'rb' )

                zip_io = io.BytesIO()
                with zipfile.ZipFile(zip_io, mode='w', compression=zipfile.ZIP_DEFLATED) as backup_zip:
                    backup_zip.writestr('CSVファイル.csv', fp.read())
                response = HttpResponse(zip_io.getvalue(), content_type='application/x-zip-compressed')
                response['Content-Disposition'] = 'attachment; filename=%s' % 'your_zipfilename' + ".zip"
                response['Content-Length'] = zip_io.tell()
                fp.close()
                os.unlink(fname)
                return response

            else:
                # confirm
                context['method'] = 'confirm'
                context['title'] = []
                f_items = form_item['declared_fields']
                for item in f_items:
                    if type(content[item]) == type([]):
                        context['title'].append({'title': f_items[item].label, 'description': f_items[item].label_suffix, 'answer': ','.join(content[item])})
                    else:
                        context['title'].append({'title': f_items[item].label, 'description': f_items[item].label_suffix, 'answer': content[item]})

    # draw enquete form
    DynamicForm = DynamicEnqForm(content)
    context['enq_form'] = DynamicForm
    return render(request, "dynamic.html", context)
```

## 解説

まずはクライアントから送信された値を**綺麗**にShift_JISに変換します。
一度CP932でエンコードした後に改めてShift_JISに変換します。
ちょっと処理が複雑になっているのはlist型の場合もあるのでその処理が入ってます。

```python
            for key in formset.cleaned_data:
                if key != 'csrfmiddlewaretoken' and key != 'method':
                    if type(formset.cleaned_data[key]) != type([]):
                        content[key] = formset.cleaned_data[key].encode('cp932').decode('shift_jis')
                    else:
                        items = formset.cleaned_data[key]
                        buf = []
                        for item in items:
                            buf.append( item.encode('cp932').decode('shift_jis') )
                        content[key] = buf
```

ファイルの出力部分は変わっていません。Shift_JISとして書きだします。

```python
                fp = tempfile.NamedTemporaryFile(mode='w', newline='', encoding='shift_jis', delete=False)
                fname = fp.name
                header=[]
                for key in content:
                    header.append(key)
                writer = csv.DictWriter(fp, fieldnames=header)
                writer.writeheader()
                writer.writerow(content)
                fp.close()
```


あとはShift_JISで出力したファイルを読み込む際に、バイナリーモードで読み込みます。
そうしないとまたUnicodeになっちゃうので。

```python
                fp = open(fname, 'rb' )
```