[JavaScript] JSZipでShift_JISを扱う

# 問題
[JSZip](https://stuk.github.io/jszip/)はJavaScriptでzipファイルを読み書きすることができるライブラリである。
ところがこのライブラリは基本的にUTF-8を前提にしているので、普通に使うと、Shift_JISを扱うときに文字化けを起こしてしまう。
具体的には以下のような現象が起こる。

* Windowsで作った、日本語ファイル名を含むzipファイルを、JSZipで読み込むと、ファイル名がおかしくなる
    * JSZipで**Shift_JISのファイル名を読み込めれば**、問題が解決する
* JSZipで作った、日本語ファイル名を含むzipファイルを、Windowsで解凍すると、ファイル名がおかしくなる
    * JSZipで**Shift_JISのファイル名を書き込めれば**、問題が解決する
* Shift_JISで保存した、テキストファイルの内容を、JSZipで読み込むと、文字化けした文字列になってしまう
    * JSZipで**Shift_JISのファイルの内容を読み込めれば**、問題が解決する
* JSZipで作ったテキストファイルの文字コードをShift_JISにしたいのに、UTF-8固定になる
    * JSZipで**Shift_JISのファイルの内容を書き込めれば**、問題が解決する

そこで、以下にそれぞれの対処法を紹介していく。

# 前提知識
この後紹介する各対処法が、何をしているのかを理解するには、以下の前提知識を把握しておく必要がある。
あなたが急いでいて、とりあえず対処法のコードをコピペするのが当面の目的であるならば、この節は読み飛ばしてもよい。

## 文字コードとは
文字列をファイルに保存する際、ファイル上には文字列をそのまま文字列として保存することはできず、バイト列に変換して保存しなければならない。
この変換の方式を定めたものが文字コードである。

UTF-8という文字コードでは`"あいう"`という文字列は`0xE3, 0x81, 0x82, 0xE3, 0x81, 0x84, 0xE3, 0x81, 0x86`というバイト列と相互変換できる。
また、Shift_JISという文字コードでは、`"あいう"`という文字列は`0x82, 0xA0, 0x82, 0xA2, 0x82, 0xA4`というバイト列と相互変換できる。

## ファイル名とファイルの内容はバイト列として保存される
ファイル名も、ファイルの内容も、それをそのまま文字列として保存することはできず、バイト列として保存しなければならない。
これはzipファイルの中のファイルであっても同様である。

## JSZipはファイル名とファイルの内容をUTF-8のバイト列として扱う
ファイル名も、ファイルの内容も、ただのバイト列としてしか保存されないので、仕方なく、JSZipはこのバイト列を暗黙にUTF-8として解釈する。
言い換えると、JSZipは、ファイル名であっても、ファイルの内容であっても、それらに文字列としてアクセスする場合には、仕方なくUTF-8を暗黙に仮定してバイト列との間の変換を行う。

## JSZipには元々のバイト列にアクセスできるAPIがある
ファイル名とファイルの内容に文字列としてアクセスする場合の挙動は上述の通りである。
しかし、JSZipは、ファイル名、ファイルの内容ともに、文字列ではなくバイト列としてアクセスするAPIも持っている。

そこで、JSZipが行っている、バイト列と文字列との間のUTF-8変換を、Shift_JIS変換を行う自前のコードに差し替えてしまえば、JSZipでShift_JISを扱うことができるようになる。
自前と言っても、完全にスクラッチで書くのは現実的でないので、[iconv-lite](https://github.com/ashtuchkin/iconv-lite)や[text-encoding](https://github.com/inexorabletash/text-encoding)のような、バイト列と文字列との間の変換を行ってくれる別のライブラリを使うことになるだろう。

## Shift_JISとCP932
変換ライブラリによっては、`"Shift_JIS"`の代わりに`"CP932"`という文字コードを指定する必要がある場合がある。
それがどういう場合かについて説明する。

CP932とは、Shift_JISを拡張した、Shift_JISのWindows方言である。
Shift_JISとCP932は厳密には異なる文字コードであり、扱える文字の種類数や、UTF-8との間の変換の挙動に微妙な差異がある。

現代の世の中において、純粋なShift_JISはあまり使われることはなく、実用的にはほとんどの場合CP932が使われる。
そのため、慣用的にはCP932のことをShift_JISと呼ぶことも多い。
この記事でも、わかりやすさのためにShift_JISという言葉を使ってはいるものの、その実体としてはCP932のことを指してこの用語を使っている。

ただ、あくまで、Shift_JISとCP932は厳密には異なる文字コードなので、文字コードを扱うライブラリによっては、この両者を厳密に使い分ける必要があるものもある。
例えばiconv-liteにおいては、CP932を扱う場合、エンコード・デコード関数に`"Shift_JIS"`ではなく`"CP932"`という引数を渡さなければならない。
一方、text-encodingはこの面では寛容で、`"Shift_JIS"`はCP932のことを指しているので、`"Shift_JIS"`という引数を渡せばよい。

## iconv-liteとtext-encodingの使い方
この前提知識の節では、文字コードを扱うライブラリの中でも著名と思われる、iconv-liteとtext-encodingの二つについて、その使い方を紹介する。
Node.jsで簡単に動かしたければiconv-lite、ブラウザで簡単に動かしたければtext-encodingの方が向いていると思うが、Browserifyなどが絡んでくるとどちらも大した差は無い。

### iconv-lite

#### Node.jsで使う方法

```bash:インストール
npm install --save iconv-lite
```

```javascript:JavaScript
const {Buffer} = require("buffer");
const iconv = require("iconv-lite");
console.log(iconv.decode(new Buffer([0x82, 0xA0, 0x82, 0xA2, 0x82, 0xA4]), "CP932")); //=> "あいう"
console.log(iconv.encode("あいう", "CP932")); //=> <Buffer 82 a0 82 a2 82 a4>
```

#### CDN経由でブラウザで使う方法

ない。
[iconv-liteは現時点（v0.4）ではBrowserifyなどを経由せずに使うシチュエーションをサポートしていない。](https://github.com/ashtuchkin/iconv-lite/issues/58)
[jsDelivrがCDNを提供しているように見える](https://www.jsdelivr.com/package/npm/iconv-lite)かもしれないが、これはjsDelivrが自動生成しただけの、実際には使えないファイルである。
したがって我々は、v0.4時点では以下のようにして、Browserifyなどを経由して使う必要がある。

#### Node.js経由でブラウザで使う方法

```bash:インストール
npm install --save iconv-lite buffer
# その他BabelとかBrowserifyとかwebpackとかお好みで
```

```javascript:JavaScript
import {Buffer} from "buffer";
import iconv from "iconv-lite";
console.log(iconv.decode(new Buffer([0x82, 0xA0, 0x82, 0xA2, 0x82, 0xA4]), "CP932")); //=> "あいう"
console.log(iconv.encode("あいう", "CP932")); //=> <Buffer 82 a0 82 a2 82 a4>
```

### text-encoding

#### Node.jsで使う方法

```bash:インストール
npm install --save text-encoding
```

```javascript:JavaScript
const {TextDecoder, TextEncoder} = require("text-encoding");
const decoder = new TextDecoder("Shift_JIS");
const encoder = new TextEncoder("Shift_JIS", {NONSTANDARD_allowLegacyEncoding: true});
console.log(decoder.decode(new Uint8Array([0x82, 0xA0, 0x82, 0xA2, 0x82, 0xA4]))); //=> "あいう"
console.log(encoder.encode("あいう")); //=> Uint8Array [ 130, 160, 130, 162, 130, 164 ]
```

#### CDN経由でブラウザで使う方法

```html:HTML
<!DOCTYPE html>
<html lang="ja">
  <head>
    <meta charset="UTF-8" />
    <title>HTML</title>
    <script>window.TextEncoder = window.TextDecoder = null;</script>
    <script src="https://cdn.jsdelivr.net/npm/text-encoding@0.6.4/lib/encoding-indexes.js"></script>
    <script src="https://cdn.jsdelivr.net/npm/text-encoding@0.6.4/lib/encoding.js"></script>
    <script src="main.js"></script>
  </head>
  <body>
    <!-- 本文 -->
  </body>
</html>
```

```javascript:JavaScript（main.js）
const decoder = new TextDecoder("Shift_JIS");
const encoder = new TextEncoder("Shift_JIS", {NONSTANDARD_allowLegacyEncoding: true});
console.log(decoder.decode(new Uint8Array([0x82, 0xA0, 0x82, 0xA2, 0x82, 0xA4]))); //=> "あいう"
console.log(encoder.encode("あいう")); //=> Uint8Array [ 130, 160, 130, 162, 130, 164 ]
```

#### Node.js経由でブラウザで使う方法

```bash:インストール
npm install --save text-encoding
# その他BabelとかBrowserifyとかwebpackとかお好みで
```

```javascript:JavaScript
import {TextDecoder, TextEncoder} from "text-encoding";
const decoder = new TextDecoder("Shift_JIS");
const encoder = new TextEncoder("Shift_JIS", {NONSTANDARD_allowLegacyEncoding: true});
console.log(decoder.decode(new Uint8Array([0x82, 0xA0, 0x82, 0xA2, 0x82, 0xA4]))); //=> "あいう"
console.log(encoder.encode("あいう")); //=> Uint8Array [ 130, 160, 130, 162, 130, 164 ]
```

# 対処法
JSZipはUTF-8を前提にした動作をするが、zipファイル内のデータをバイト列として扱い、自前で文字列との間の変換をすれば、Shift_JIS（さらにはShift_JISを含むすべての文字コード）を扱うことができる。
自前でバイト列と文字列との間の変換をするには、iconv-liteのような変換ライブラリを使えばよいが、この際、ライブラリによっては、文字コードとしてShift_JISではなくCP932を指定せねばならないことに注意する。

## Shift_JISのファイル名を読み込む

```javascript:iconv-lite版
(async () => {
  // get data from somewhere
  const input = await JSZip.loadAsync(data, {
    decodeFileName: fileNameBinary => iconv.decode(fileNameBinary, "CP932")
  });
  const fileObject = input.file("日本語.txt");
  // do something with fileObject
})();
```

```javascript:text-encoding版
(async () => {
  // get data from somewhere
  const decoder = new TextDecoder("Shift_JIS");
  const input = await JSZip.loadAsync(data, {
    decodeFileName: fileNameBinary => decoder.decode(fileNameBinary)
  });
  const fileObject = input.file("日本語.txt");
  // do something with fileObject
})();
```

`decodeFileName`というオプションが肝である。
`decodeFileName`には、ファイル名を表すバイト列を、Shift_JISとして解釈した文字列にして返す関数を設定する。

## Shift_JISのファイル名を書き込む

```javascript:iconv-lite版
(async () => {
  const output = new JSZip();
  output.file("日本語.txt", "foobar");
  const outputBlob = await output.generateAsync({
    type: "blob",
    encodeFileName: fileName => iconv.encode(fileName, "CP932")
  });
  // do something with outputBlob
})();
```

```javascript:text-encoding版
(async () => {
  const encoder = new TextEncoder("Shift_JIS", {NONSTANDARD_allowLegacyEncoding: true});
  const output = new JSZip();
  output.file("日本語.txt", "foobar");
  const outputBlob = await output.generateAsync({
    type: "blob",
    encodeFileName: fileName => encoder.encode(fileName)
  });
  // do something with outputBlob
})();
```

`encodeFileName`というオプションが肝である。
`encodeFileName`には、ファイル名を表す文字列を、Shift_JISのバイト列にして返す関数を設定する。

## Shift_JISのファイルの内容を読み込む

```javascript:iconv-lite版
(async () => {
  // get data from somewhere
  const input = await JSZip.loadAsync(data);
  const fileContentBinary = await input.file("file.txt").async("uint8array");
  const fileContentString = iconv.decode(fileContentBinary, "CP932");
  // do something with fileContentString
})();
```

```javascript:text-encoding版
(async () => {
  // get data from somewhere
  const decoder = new TextDecoder("Shift_JIS");
  const input = await JSZip.loadAsync(data);
  const fileContentBinary = await input.file("file.txt").async("uint8array");
  const fileContentString = decoder.decode(fileContentBinary);
  // do something with fileContentString
})();
```

ファイルの内容をバイト列として読み込み、自前で文字列に戻している。

## Shift_JISのファイルの内容を書き込む

```javascript:iconv-lite版
(async () => {
  const output = new JSZip();
  const fileContentBinary = iconv.encode("日本語", "CP932");
  output.file("file.txt", fileContentBinary);
  const outputBlob = await output.generateAsync({type: "blob"});
  // do something with outputBlob
})();
```

```javascript:text-encoding版
(async () => {
  const encoder = new TextEncoder("Shift_JIS", {NONSTANDARD_allowLegacyEncoding: true});
  const output = new JSZip();
  const fileContentBinary = encoder.encode("日本語");
  output.file("file.txt", fileContentBinary);
  const outputBlob = await output.generateAsync({type: "blob"});
  // do something with outputBlob
})();
```

文字列を自前でバイト列に変換し、ファイルの内容をバイト列として書き込んでいる。

# まとめ
zipファイルにはバイト列の情報しか保存されていない。
そのため、JSZipはバイト列をとりあえずUTF-8として解釈し、文字列に変換する動作をする。
バイト列を別の文字コードとして解釈したければ、バイト列と文字列との間の変換を自前で行えばよい。