Sansan Tech Blog

Sansanのものづくりを支えるメンバーの技術やデザイン、プロダクトマネジメントの情報を発信

ハイフンに似た文字が Shift_JIS でエンコードできない問題とその解決策

初めまして!
2022年の3月に入社しました渡邉です。
現在はデータ戦略部という部署に所属しておりまして、多種多様なデータを収集・活用するためのサービス開発に携わっています。

タイトルにある通り、今更になって Shift_JIS と格闘する羽目になったのですが、その背景として長期に渡って稼働していたシステムの一部をリプレイスすることになったことがあります。
旧システムは他社のシステムとデータを Shift_JIS でエンコードしたファイルでやりとりしているのですが、これは他社が保守しているシステムとのファイル連携であり、リプレイスプロジェクト期間中に連携ファイル仕様を変えることは現実的ではないという判断です。

背景

突然ですが、皆さまはこれらの文字の違いがわかりますか?

1.
2.
3.

正解はそれぞれ、

1. 全角のマイナス
2. ハイフン
3. 漢字の一

となっています。
そして、この中に1つだけ iconv-lite というnpmライブラリを用いると Shift_JIS で正しくエンコードできないものがあります。

iconv-lite とは

www.npmjs.com

Node.js で文字コードを変換するためのモジュールです。
さまざまな文字コードに対応しており、Node.js で文字コードを変換するといえばまずこのライブラリの名前が上がるのではないでしょうか。
iconv-lite で Shift_JIS にエンコードできない文字に遭遇したため、この記事ではその詳細と解決策を紹介します。

iconv-lite によるエンコード

例えば「あ」という文字は以下のような文字コードになっています。

Shift_JIS 82 A0
UTF-8 E3 81 82

では、実際に iconv-lite を使って「あ」という文字を Shift_JIS と UTF-8 にエンコードしてみます。
(CP932 は厳密に言えば SJIS とは異なりますが、CP932 の方が Shift_JIS よりも文字集合が広いため問題はありません。)

import * as iconv from 'iconv-lite';

const text = 'あ';

const sjis = iconv.encode(text, 'CP932');
const utf8 = iconv.encode(text, 'utf-8');

console.log(sjis);
console.log(utf8);
<Buffer 82 a0>
<Buffer e3 81 82>

このようにエンコードできました。試しにこれらの Buffer をデコードしてみます。

import * as iconv from 'iconv-lite';

const sjis = Buffer.from([0x82, 0xa0]);
const utf8 = Buffer.from([0xe3, 0x81, 0x82]);

console.log(iconv.decode(sjis, 'CP932'));
console.log(iconv.decode(utf8, 'utf-8'));
あ
あ

このように元の文字に戻すことができます。

正しくエンコードできない文字

では、「−(全角マイナス)」を同じようにエンコードしてみます。
全角マイナスは以下のような文字コードになっています。

Shift_JIS 81 7C
UTF-8 E2 88 92
import * as iconv from 'iconv-lite';

const text = '−';

const sjis = iconv.encode(text, 'CP932');
const utf8 = iconv.encode(text, 'utf-8');

console.log(sjis);
console.log(utf8);
<Buffer 3f>
<Buffer e2 88 92>

何かおかしいですね。
UTF-8 には正しくエンコードできていますが、Shift_JIS でのエンコード結果が <Buffer 3f> となってしまっています。

では、これをデコードしてみます。

import * as iconv from 'iconv-lite';

const sjis = Buffer.from([0x3f]);
const utf8 = Buffer.from([0xe2, 0x88, 0x92]);

console.log(iconv.decode(sjis, 'CP932'));
console.log(iconv.decode(utf8, 'utf-8'));
?
−

このように、Shift_JIS でデコードした方が ? になってしまっていますね🤔

なぜ ? にエンコードされるのか

iconv-lite のソースコードを見てみると、Shift_JIS の文字を管理しているファイルに全角のマイナスが存在せず、Unicode と Shift_JIS のマッピングを持っていないことが原因だと考えられます。
なぜマッピングが存在しないかは、あくまで予想ですが異なる文字で Shift_JIS の文字コードが同じものがあるため、変換先を一意に特定できないからだと思われます。

文字コード (全角のマイナス) (ハイフン)
Shift_JIS 81 7C 81 7C
UTF-8 E2 88 92 EF BC 8D

https://github.com/ashtuchkin/iconv-lite/blob/master/encodings/tables/shiftjis.json

マッピングが存在しない場合、iconv-liteは ? に変換する仕様があるようです。

iconv.defaultCharSingleByte = '?';

そのため、うまく Shift_JIS に変換できず、? になってしまっていると予想されます。

解決策

エンコードできない文字に遭遇した場合、以下のようなアプローチが取れると考えます。

1. 他の文字(似た文字)に変換する
2. エンコードできない文字が含まれている旨を通知する

今回は正確性が求められているので、2. エンコードできない文字が含まれている旨を通知する の案を採用しました。

まず、これを実現するために必要なことは「変換に失敗した文字を検知する」ことです。
しかし、iconv-lite では、変換に失敗した文字が置換される文字である ? を他の文字に変更できないため、変換前の文字列に ? が含まれると正常に動作しなくなってしまいます。
そこで、今回は別のnpmライブラリを利用してこの問題を解決します。

www.npmjs.com

encoding-japaneseiconv-lite と同じように文字コードを変換するためのモジュールですが、エンコードできない文字を見つけたときにフォールバックする機能があります。

var unicodeArray = Encoding.stringToCode('寿司🍣ビール🍺');

// Specify `fallback: html-entity`
sjisArray = Encoding.convert(unicodeArray, {
  to: 'SJIS',
  from: 'UNICODE',
  fallback: 'html-entity'
});
console.log(sjisArray); // Converted to a code array of '寿司&#127843;ビール&#127866;'

フォールバックされた文字列はHTMLエンティティで返されるため、これをデコードするためのライブラリも導入します。

www.npmjs.com

このフォールバック機能を利用して、以下のようにコードを書いてみました。

import * as Encoding from 'encoding-japanese';
import { decode } from 'html-entities';

const text = '🍣食べたい🍺飲みたい𩸽も食べたい';

const htmlEntityRegex = /&#[\d{,7}]+;/g;
const encodedArray = Encoding.convert(Encoding.stringToCode(text), {
  to: 'SJIS',
  from: 'UNICODE',
  fallback: 'html-entity',
});
const encodedValue = Buffer.from(encodedArray).toString();
const invalidTexts = encodedValue.match(htmlEntityRegex) as Array<string>;

if (invalidTexts.length) {
  console.log(decode(invalidTexts.flat().join(', ')));
}

このコードを実行すると、

🍣, 🍺, 𩸽

このように、UTF-8 から Shift_JIS にエンコードできない文字のみ抽出できます。
対象の文字列にHTMLエンティティが含まれる場合は変換前に何かしらの処理が必要ですが、基本的にはこのやり方で 2. エンコードできない文字が含まれている旨を通知する を実現できると思います。

感想

今回は UTF-8 から Shift_JIS にエンコードできない文字を抽出する方法を紹介しました。

普段あまりライブラリのソースコードを詳細に読むことはないのですが、読んでみると自分の知らない書き方などが結構あったりして勉強になりました。
今後もこのような機会があった時は、しっかりとコードを読んで理解し知見を広げたいなと思います!

© Sansan, Inc.