オブジェクトの広場はオージス総研グループのエンジニアによる技術発表サイトです

プログラミング

(プログラマのための)
いまさら聞けない標準規格の話

第2回 文字コード実践編
オージス総研 技術部 アドバンストテクノロジセンター
伊藤 喜一
2021年8月19日

プログラマがシステム開発において共通で必要となる、技術と業務の狭間の共通知識を解説します。連載第2回は文字コードの実践編です。

0. 前回の復習と今回の概要

システム開発で必要となる標準規格の話、前回 は文字コードの概要について説明しました。ざっくりまとめるとこんな内容でした。

  • 「符号化文字集合」で文字集合と符号位置を定義し、「符号化方式」でバイト表現に変換していること。
  • 日本では、しばらく文字集合 JIS X 0208 を、ISO-2022-JP、EUC-JP、Shift_JIS の符号化方式で利用してきたこと。
  • 近年は、世界中の文字が扱える Unicode が主流となっており、UTF-8、UTF-16 などの符号化方式があること。
  • 常用漢字、人名用漢字に限っても、字体を正確に扱おうとすると、JIS X 0208 の範囲では不十分であり、JIS X 0213、Unicode、サロゲートペア、Unicode正規化、異体字セレクタ等の知識が必要になること。

連載第2回は文字コードの実践編です。プログラミングでの扱い方、システム開発において特に注意すべき問題点と対策案を解説します。

1. プログラミング言語での扱い (Java)

本章では、Java を例に、プログラミング言語で文字コードを扱う方法 (文字列の走査、文字コード変換、Unicode 正規化) を説明します。
コード例は Java 11 を基準にしています。

Java は char 型で文字1つを、String クラスで char の並びとしての文字列を管理します。charString の実体は UTF-16 形式の Unicode です。したがって、基本多言語面 (BMP) 以外の文字はサロゲートペアで表現されます。

1-1. 文字列の走査

本節では、文字列を構成する文字を順に走査し、処理してみます。

char 単位の走査

一番簡単なのは char ごとに走査する方法です。for 文で回すこともできますが、今どきっぽくラムダ式で書いてみました。String#chars()charint に拡張した IntStream が返されます。

class CharsetsTest {

    public static void main(String[] args) throws Exception {
        testChars();
        // ...
    }

    static void testChars() {

        String text = "Aa1アガA1アガ亜\u00A5¥ ̄Å①彅\r\n"
                + "\uD842\uDF9F\uD84D\uDD94神神\uFE00カ\u309A\u02E9\u02E5";

        // char 単位で処理を行う。
        text.chars().forEach(c -> {
            if (Character.isISOControl(c)) {
                System.out.printf("%04X (%s)%n", c, getName(c));
            } else {
                System.out.printf("%04X [%c]%n", c, (char) c);
            }
        });
    }

    static String getName(int codePoint) {
        switch (codePoint) {
        case '\r':
            return "CR";
        case '\n':
            return "LF";
        default:
            return Character.getName(codePoint);
        }
    }

}

実行結果:

0041 [A]
0061 [a]
0031 [1]
FF71 [ア]
FF76 [カ]
FF9E [゙]
FF21 [A]
FF11 [1]
30A2 [ア]
30AC [ガ]
4E9C [亜]
00A5 [¥]
FFE5 [¥]
FFE3 [ ̄]
212B [Å]
2460 [①]
5F45 [彅]
000D (CR)
000A (LF)
D842 [?]
DF9F [?] ← サロゲートペア
D84D [?]
DD94 [?]
FA19 [神]
795E [神]
FE00 [︀] ← 異体字シーケンス
30AB [カ]
309A [゚] ← 結合文字列
02E9 [˩]
02E5 [˥]

char 単位の走査では、BMP の文字は正しく処理できますが、サロゲートペアや結合文字列、異体字シーケンスは複数の char で表現されるので、正しく1文字として扱えません。

コードポイント (符号位置) 単位の走査

次に、コードポイント (符号位置) 単位に走査してみます。String#codePoints() でコードポイントのストリームが IntStream で返されます。

import java.util.stream.Collectors;

// ...

    static void testCodePoints() {

        String text = "Aa1アガA1アガ亜\u00A5¥ ̄Å①彅\r\n"
                + "\uD842\uDF9F\uD84D\uDD94神神\uFE00カ\u309A\u02E9\u02E5";

        // コードポイント単位で処理を行う。
        text.codePoints().forEach(cp -> {
            String s = codePointsToString(cp);
            if (Character.isISOControl(cp)) {
                System.out.printf("U+%04X (%s) (%s)%n", cp, toHexString(s), getName(cp));
            } else {
                System.out.printf("U+%04X (%s) [%s]%n", cp, toHexString(s), s);
            }
        });
    }

    static String codePointsToString(int... codePoints) {
        return new String(codePoints, 0, codePoints.length);
    }

    static String toHexString(String s) {
        return s.chars()
            .mapToObj(cp -> String.format("%04X", cp))
            .collect(Collectors.joining(" "));
    }

実行結果:

U+0041 (0041) [A]
U+0061 (0061) [a]
U+0031 (0031) [1]
U+FF71 (FF71) [ア]
U+FF76 (FF76) [カ]
U+FF9E (FF9E) [゙]
U+FF21 (FF21) [A]
U+FF11 (FF11) [1]
U+30A2 (30A2) [ア]
U+30AC (30AC) [ガ]
U+4E9C (4E9C) [亜]
U+00A5 (00A5) [¥]
U+FFE5 (FFE5) [¥]
U+FFE3 (FFE3) [ ̄]
U+212B (212B) [Å]
U+2460 (2460) [①]
U+5F45 (5F45) [彅]
U+000D (000D) (CR)
U+000A (000A) (LF)
U+20B9F (D842 DF9F) [𠮟] ← サロゲートペア
U+23594 (D84D DD94) [𣖔]
U+FA19 (FA19) [神]
U+795E (795E) [神]
U+FE00 (FE00) [︀] ← 異体字シーケンス
U+30AB (30AB) [カ]
U+309A (309A) [゚] ← 結合文字列
U+02E9 (02E9) [˩]
U+02E5 (02E5) [˥]

サロゲートペアが正しく処理できるようになりました。ただし、結合文字列、異体字シーケンスは複数のコードポイントで表現されるので、正しく1文字として扱えていません。

BreakIterator による走査

java.text.BreakIterator は文字列の境界を見つけて処理する機能を提供します。BreakIterator.getCharacterInstance() で文字分割用の BreakIterator を取得できます。

import java.text.BreakIterator;
import java.util.Locale;

// ...

    static void testBreakIterator() {

        String text = "Aa1アガA1アガ亜\u00A5¥ ̄Å①彅\r\n"
                + "\uD842\uDF9F\uD84D\uDD94神神\uFE00カ\u309A\u02E9\u02E5";

        // 文字分割用の BreakIterator を取得する。
        BreakIterator bi = BreakIterator.getCharacterInstance(Locale.JAPAN);
        // 処理対象のテキストを設定する。
        bi.setText(text);

        // 1文字ずつ処理を行う。
        for (int start = bi.first(), end = bi.next();
                end != BreakIterator.DONE; start = end, end = bi.next()) {
            String s = text.substring(start, end);
            if (Character.isISOControl(s.charAt(0))) {
                System.out.printf("%s (%s)%n", toHexString(s), getName(s));
            } else {
                System.out.printf("%s [%s]%n", toHexString(s), s);
            }
        }
    }

    static String getName(String s) {
        return s.codePoints()
            .mapToObj(CharsetsTest::getName)
            .collect(Collectors.joining("+"));
    }

実行結果:

0041 [A]
0061 [a]
0031 [1]
FF71 [ア]
FF76 [カ]
FF9E [゙]
FF21 [A]
FF11 [1]
30A2 [ア]
30AC [ガ]
4E9C [亜]
00A5 [¥]
FFE5 [¥]
FFE3 [ ̄]
212B [Å]
2460 [①]
5F45 [彅]
000D 000A (CR+LF) ← 改行コード
D842 DF9F [𠮟] ← サロゲートペア
D84D DD94 [𣖔]
FA19 [神]
795E FE00 [神︀] ← 異体字シーケンス
30AB 309A [カ゚] ← 結合文字列
02E9 [˩]
02E5 [˥] ← これも結合文字列

サロゲートペア、結合文字列、異体字シーケンス、いずれも正しく1文字として処理することができました。加えて、改行コード CR+LF も1文字として扱われています。
ただし、02E9 02E5 はそれぞれ基底文字であるせいか別の文字として扱われています。要件次第ですが、02E9 02E5 を結合文字列として扱いたい場合は、次の正規表現を用いるとよいと思います。

正規表現を用いた走査

正規表現を用いれば文字列を好きな位置で分割して処理することが可能です。Unicode の書記素クラスタ (grapheme cluster) を利用すると、正規表現 \X で「基底文字+結合文字」の並びにマッチできるようです。なお、Java の文字列では \\\ とエスケープします。
コード例では、書記素クラスタに加えて 02E9 02E502E5 02E9 も1文字として扱う実装にしています。

import java.util.regex.Pattern;

// ...

    /**
     * 論理的な文字を表す正規表現のパターン。
     */
    static final Pattern CHAR_PATTERN =
            Pattern.compile("\\u02E9\\u02E5|\\u02E5\\u02E9|\\X");

    static void testRegex() {

        String text = "Aa1アガA1アガ亜\u00A5¥ ̄Å①彅\r\n"
                + "\uD842\uDF9F\uD84D\uDD94神神\uFE00カ\u309A\u02E9\u02E5";

        // 正規表現にマッチした単位で処理を行う。
        CHAR_PATTERN.matcher(text).results().forEach(r -> {
            String s = r.group();
            if (Character.isISOControl(s.charAt(0))) {
                System.out.printf("%s (%s)%n", toHexString(s), getName(s));
            } else {
                System.out.printf("%s [%s]%n", toHexString(s), s);
            }
        });
    }

実行結果:

0041 [A]
0061 [a]
0031 [1]
FF71 [ア]
FF76 FF9E [ガ] ← 半角片仮名の濁音
FF21 [A]
FF11 [1]
30A2 [ア]
30AC [ガ]
4E9C [亜]
00A5 [¥]
FFE5 [¥]
FFE3 [ ̄]
212B [Å]
2460 [①]
5F45 [彅]
000D 000A (CR+LF) ← 改行コード
D842 DF9F [𠮟] ← サロゲートペア
D84D DD94 [𣖔]
FA19 [神]
795E FE00 [神︀] ← 異体字シーケンス
30AB 309A [カ゚] ← 結合文字列
02E9 02E5 [˩˥] ← これも結合文字列

サロゲートペア、結合文字列、異体字シーケンスが、いずれも1文字として処理することができました。加えて、改行コード CR+LF、半角片仮名の濁音も1文字として扱われています。

以上、BreakIterator や正規表現を用いれば、どんな種類の文字でも正しく1文字として処理することができますが、BMP の文字しか扱わないのであれば String#chars() の方がパフォーマンス的に優位ではあるので、適材適所で使い分けるのがよいと思います。

1-2. String クラスによるエンコード/デコード

本節では、String クラスを用いた、Unicode と各文字コードとのコード変換を説明します。具体的には、Unicode (UTF-16) を表す文字列 (String) と、各文字コードを表すバイト配列 (byte[]) との変換になります。
Unicode (String) から各文字コード (byte[]) への変換をエンコード (encode)、各文字コード (byte[]) から Unicode (String) への変換をデコード (decode) と呼びます。

Unicode から各文字コードへの変換 (エンコード)

String#getBytes() で Unicode (String) から各文字コード (byte[]) へ変換ができます。
変換先の文字コードは、エンコーディング名を文字列で指定するか、java.nio.charset.Charset クラスを指定します。

import java.io.UnsupportedEncodingException;
import java.nio.charset.Charset;
import java.util.stream.IntStream;

// ...

    // Shift_JIS-2004 の Charset を取得する。
    static final Charset SHIFT_JIS_2004 = Charset.forName("x-SJIS_0213");

    static void testEncoding() throws UnsupportedEncodingException {

        String text = "Aa1アガA1アガ亜\u00A5¥ ̄Å①彅\r\n"
                + "\uD842\uDF9F\uD84D\uDD94神神\uFE00カ\u309A\u02E9\u02E5";

        // Unicode (UTF-16) から Windows-31J に変換 (エンコーディング名指定)。
        byte[] w31j = text.getBytes("Windows-31J");
        System.out.println("Windows-31J:");
        System.out.println(toHexString(w31j));

        // Unicode (UTF-16) から Shift_JIS-2004 に変換 (Charset クラス指定)。
        byte[] sjis2004 = text.getBytes(SHIFT_JIS_2004);
        System.out.println("Shift_JIS-2004:");
        System.out.println(toHexString(sjis2004));
    }

    static String toHexString(byte[] a) {
        return IntStream.range(0, a.length)
            .mapToObj(i -> String.format("%02X", a[i]))
            .collect(Collectors.joining(" "));
    }

実行結果:

Windows-31J:
41 61 31 B1 B6 DE 82 60 82 50 83 41 83 4B 88 9F 5C 81 8F 81 50 81 F0 87 40 FA 67 0D 0A 3F 3F FB 7E 90 5F 3F 83 4A 3F 3F 3F
Shift_JIS-2004:
41 61 31 B1 B6 DE 82 60 82 50 83 41 83 4B 88 9F 3F 81 8F 81 50 81 F0 87 40 EA B8 0D 0A 98 73 F4 49 ED 5B 90 5F 3F 83 97 86 85

変換できない文字は置換文字 (3F (?) など) に置き換わります。

各文字コードから Unicode への変換 (デコード)

各文字コード (byte[]) から Unicode (String) への変換は String クラスのコンストラクタを用います。 変換元の文字コードは、エンコードと同様に、エンコーディング名を文字列で指定するか、java.nio.charset.Charset クラスを指定します。

    static void testDecoding() throws UnsupportedEncodingException {

        byte[] sjis = bytes(
                // JIS X 0201、JIS X 0208 など。
                0x41, 0x61, 0x31, 0xB1, 0xB6, 0xDE,
                0x82, 0x60, 0x82, 0x50, 0x83, 0x41, 0x83, 0x4B, 0x88, 0x9F,
                // Windows-31J など。
                0x5C, 0x81, 0x8F, 0x81, 0x50, 0x81, 0xF0,
                0x87, 0x40, 0xFA, 0x67, 0xED, 0x4B, 0xFB, 0x7E, 0xEE, 0x62, 0x0D, 0x0A,
                // JIS X 0213 など。
                0x98, 0x73, 0xF4, 0x49, 0xED, 0x5B,
                0x83, 0x97, 0x86, 0x85);

        // Windows-31J から Unicode (UTF-16) に変換する (エンコーディング名指定)。
        String w31jText = new String(sjis, "Windows-31J");
        System.out.println("Windows-31J:");
        System.out.println(toHexString(w31jText));
        System.out.println(w31jText);

        // Shift_JIS-2004 から Unicode (UTF-16) に変換する (Charset クラス指定)。
        String sjis2004Text = new String(sjis, SHIFT_JIS_2004);
        System.out.println("Shift_JIS-2004:");
        System.out.println(toHexString(sjis2004Text));
        System.out.println(sjis2004Text);
    }

    /**
     * {@code int} の並びからバイト配列を生成します。
     *
     * @param values {@code int} の並び
     * @return バイト配列
     */
    static byte[] bytes(int... values) {
        byte[] bytes = new byte[values.length];
        for (int i = 0; i < values.length; ++i) {
            bytes[i] = (byte) values[i];
        }
        return bytes;
    }

実行結果:

Windows-31J:
0041 0061 0031 FF71 FF76 FF9E FF21 FF11 30A2 30AC 4E9C 005C FFE5 FFE3 212B 2460 5F45 5F45 FA19 FA19 000D 000A FFFD 0073 E2F9 501E FFFD 87BA FFFD
Aa1アガA1アガ亜\¥ ̄Å①彅彅神神 ← ベンダー外字も正しくデコード
�s倞�螺� ← JIS X 0213 固有文字は文字化け
Shift_JIS-2004:
0041 0061 0031 FF71 FF76 FF9E FF21 FF11 30A2 30AC 4E9C 005C FFE5 FFE3 212B 2460 D860 DCBB 78F2 9633 85ED 000D 000A D842 DF9F D84D DD94 FA19 30AB 309A 02E9 02E5
Aa1アガA1アガ亜\¥ ̄Å①𨂻磲阳藭 ← ベンダー外字は文字化け
𠮟𣖔神カ゚˩˥ ← JIS X 0213 固有文字は正しくデコード

変換できないバイト列は置換文字 (FFFD) に置き換わります。
Windows-31J のベンダー外字を Shift_JIS-2004 で、JIS X 0213 固有文字を Windows-31J でデコードすると文字化けが発生します。

1-3. CharsetEncoder / CharsetDecoder によるエンコード/デコード

Java NIO (New I/O API) のクラスを使うと、よりきめ細かいエンコード/デコードが可能になります。

Unicode から各文字コードへの変換 (エンコード)

Charset#newEncoder()java.nio.charset.CharsetEncoder のインスタンスを取得し、CharsetEncoder#encode() で、Unicode から各文字コードへ変換します。Stringbyte[] の代わりに java.nio.CharBufferjava.nio.ByteBuffer を使用する必要があるため、コード例ではメソッドに切り出しました。

import java.nio.ByteBuffer;
import java.nio.CharBuffer;
import java.nio.charset.CharacterCodingException;
import java.nio.charset.Charset;

// ...

    // Windows-31J の Charset を取得する。
    static final Charset WINDOWS_31J = Charset.forName("Windows-31J");

    static void testEncoder() throws CharacterCodingException {

        String text = "Aa1アガA1アガ亜\u00A5¥ ̄Å①彅\r\n"
                + "\uD842\uDF9F\uD84D\uDD94神神\uFE00カ\u309A\u02E9\u02E5";

        byte[] w31j = encode(source, WINDOWS_31J);
        System.out.println("Windows-31J:");
        System.out.println(toHexString(w31j));
    }

    /**
     * 文字列を指定された文字コードでバイト配列にエンコードします。
     *
     * @param s       文字列
     * @param charset 文字コード
     * @return        バイト配列
     * @throws CharacterCodingException エンコードに失敗した場合
     */
    static byte[] encode(CharSequence s, Charset charset)
            throws CharacterCodingException {
        CharBuffer in = CharBuffer.wrap(s);
        ByteBuffer out = charset.newEncoder().encode(in);
        byte[] bytes = new byte[out.remaining()];
        out.get(bytes);
        return bytes;
    }

実行結果:

java.nio.charset.UnmappableCharacterException: Input length = 2

CharsetEncoder は、変換できない文字があるとデフォルトで例外をスローします。
標準のメッセージだとエラー箇所が分かりにくいので、エラーの発生位置や入力情報を加えるなど、少し手を入れた方が良いかもしれません。

CharsetEncoder に対してオプションを指定すると、変換できない文字があったときの動作を変更することができます。サンプルでは下駄記号 (〓) に置き換えてみました。

import java.nio.charset.CharsetEncoder;
import java.nio.charset.CodingErrorAction;

// ...

    static void testEncoder2() {

        String text = "Aa1アガA1アガ亜\u00A5¥ ̄Å①彅\r\n"
                + "\uD842\uDF9F\uD84D\uDD94神神\uFE00カ\u309A\u02E9\u02E5";

        byte[] w31j = encode(source, WINDOWS_31J, "〓");
        System.out.println("Windows-31J:");
        System.out.println(toHexString(w31j));
    }

    /**
     * 文字列を指定された文字コードでバイト配列にエンコードします。
     *
     * @param s       文字列
     * @param charset 文字コード
     * @param replace エンコードに失敗した場合、置き換える文字列
     * @return        バイト配列
     */
    static byte[] encode(CharSequence s, Charset charset, String replace) {
        CharsetEncoder encoder = charset.newEncoder()
            .onMalformedInput(CodingErrorAction.REPLACE)
            .onUnmappableCharacter(CodingErrorAction.REPLACE);
        if (replace != null) {
            encoder.replaceWith(replace.getBytes(charset));
        }
        try {
            ByteBuffer out = encoder.encode(CharBuffer.wrap(s));
            byte[] bytes = new byte[out.remaining()];
            out.get(bytes);
            return bytes;
        } catch (CharacterCodingException e) {
            throw new UncheckedIOException(e);
        }
    }

実行結果:

Windows-31J:
41 61 31 B1 B6 DE 82 60 82 50 83 41 83 4B 88 9F 5C 81 8F 81 50 81 F0 87 40 FA 67 0D 0A 81 AC 81 AC FB 7E 90 5F 81 AC 83 4A 81 AC 81 AC 81 AC

81 AC が置き換えられた部分です。

各文字コードから Unicode への変換 (デコード)

Charset#newDecoder()java.nio.charset.CharsetDecoder のインスタンスを取得し、CharsetDecoder#decode() で、各文字コードから Unicode へ変換します。byte[]String の代わりに java.nio.ByteBufferjava.nio.CharBuffer を使用する必要があるため、コード例ではメソッドに切り出しました。

    static void testDecoder() throws CharacterCodingException {

        byte[] sjis = bytes(
                // JIS X 0201、JIS X 0208 など。
                0x41, 0x61, 0x31, 0xB1, 0xB6, 0xDE,
                0x82, 0x60, 0x82, 0x50, 0x83, 0x41, 0x83, 0x4B, 0x88, 0x9F,
                // Windows-31J など。
                0x5C, 0x81, 0x8F, 0x81, 0x50, 0x81, 0xF0,
                0x87, 0x40, 0xFA, 0x67, 0xED, 0x4B, 0xFB, 0x7E, 0xEE, 0x62, 0x0D, 0x0A,
                // JIS X 0213 など。
                0x98, 0x73, 0xF4, 0x49, 0xED, 0x5B,
                0x83, 0x97, 0x86, 0x85);

        String w31jStr = decode(sjis, WINDOWS_31J);
        System.out.println("Windows-31J:");
        System.out.println(toHexString(w31jStr));
        System.out.println(w31jStr);
    }

    /**
     * バイト配列を指定された文字コードで文字列にデコードします。
     *
     * @param bytes   バイト配列
     * @param charset 文字コード
     * @return        文字列
     * @throws CharacterCodingException デコードに失敗した場合
     */
    static String decode(byte[] bytes, Charset charset)
            throws CharacterCodingException {
        ByteBuffer in = ByteBuffer.wrap(bytes);
        return charset.newDecoder().decode(in).toString();
    }

実行結果:

java.nio.charset.MalformedInputException: Input length = 1

CharsetDecoder は、変換できない文字があるとデフォルトで例外をスローします。
標準のメッセージだとエラー箇所が分かりにくいので、エラーの発生位置や入力情報を加えるなど、少し手を入れた方が良いかもしれません。

CharsetDecoder に対してオプションを指定すると、変換できない文字があったときの動作を変更することができます。サンプルでは下駄記号 (〓) に置き換えてみました。

import java.nio.charset.CharsetDecoder;
import java.nio.charset.CodingErrorAction;

// ...

    static void testDecoder2() throws CharacterCodingException {

        byte[] sjis = bytes(
                // JIS X 0201、JIS X 0208 など。
                0x41, 0x61, 0x31, 0xB1, 0xB6, 0xDE,
                0x82, 0x60, 0x82, 0x50, 0x83, 0x41, 0x83, 0x4B, 0x88, 0x9F,
                // Windows-31J など。
                0x5C, 0x81, 0x8F, 0x81, 0x50, 0x81, 0xF0,
                0x87, 0x40, 0xFA, 0x67, 0xED, 0x4B, 0xFB, 0x7E, 0xEE, 0x62, 0x0D, 0x0A,
                // JIS X 0213 など。
                0x98, 0x73, 0xF4, 0x49, 0xED, 0x5B,
                0x83, 0x97, 0x86, 0x85);

        String w31jStr = decode(sjis, WINDOWS_31J, "〓");
        System.out.println("Windows-31J:");
        System.out.println(toHexString(w31jStr));
        System.out.println(w31jStr);
    }

    /**
     * バイト配列を指定された文字コードで文字列にデコードします。
     *
     * @param bytes   バイト配列
     * @param charset 文字コード
     * @param replace デコードに失敗した場合、置き換える文字列
     * @return        文字列
     */
    static String decode(byte[] bytes, Charset charset, String replace) {
        CharsetDecoder decoder = charset.newDecoder()
            .onMalformedInput(CodingErrorAction.REPLACE)
            .onUnmappableCharacter(CodingErrorAction.REPLACE);
        if (replace != null) {
            decoder.replaceWith(replace);
        }
        try {
            return decoder
                .decode(ByteBuffer.wrap(bytes))
                .toString();
        } catch (CharacterCodingException e) {
            throw new UncheckedIOException(e);
        }
    }

実行結果:

Windows-31J:
0041 0061 0031 FF71 FF76 FF9E FF21 FF11 30A2 30AC 4E9C 005C FFE5 FFE3 212B 2460 5F45 5F45 FA19 FA19 000D 000A 3013 0073 E2F9 501E 3013 87BA 3013
Aa1アガA1アガ亜\¥ ̄Å①彅彅神神
〓s倞〓螺〓

1-4. Reader / Writer によるエンコード/デコード

本節では、ファイルの読み書きと同時にエンコード/デコードする方法を説明します。

Unicode から各文字コードへの変換 (エンコード)

java.io.OutputStreamWriter クラスを使うと、文字コードを指定して、ファイルへの書き込みができます。

import java.io.BufferedWriter;
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.OutputStreamWriter;
import java.io.PrintWriter;

// ...

    static void testWriter() throws IOException {

        String text = "Aa1アガA1アガ亜\u00A5¥ ̄Å①彅\r\n"
                + "\uD842\uDF9F\uD84D\uDD94神神\uFE00カ\u309A\u02E9\u02E5";

        File dir = new File("target/output");
        dir.mkdirs();

        File file = new File(dir, "out-1-w31j.txt");
        PrintWriter out = new PrintWriter(new BufferedWriter(
                new OutputStreamWriter(new FileOutputStream(file), WINDOWS_31J)), true);
        try {
            out.println(text);
        } finally {
            out.close();
        }
    }

出力ファイル:

Aa1アガA1アガ亜\¥ ̄Å①彅
??神神?カ???

各文字コードから Unicode への変換 (デコード)

java.io.InputStreamReader クラスを使うと、文字コードを指定して、ファイルからの読み込みができます。

import java.io.BufferedReader;
import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.io.InputStreamReader;

// ...

    static void testReader() throws IOException {

        File dir = new File("src/test/resources/input");
        File file = new File(dir, "in-short-sjis.txt");

        BufferedReader in = new BufferedReader(
                new InputStreamReader(new FileInputStream(file), WINDOWS_31J));
        try {
            StringBuilder sb = new StringBuilder();
            String line;
            while ((line = in.readLine()) != null) {
                sb.append(line);
                sb.append(System.lineSeparator());
            }
            String s = sb.toString().stripTrailing();
            System.out.println("Windows-31J:");
            System.out.println(toHexString(s));
            System.out.println(s);
        } finally {
            in.close();
        }
    }

実行結果:

Windows-31J:
0041 0061 0031 FF71 FF76 FF9E FF21 FF11 30A2 30AC 4E9C 005C FFE5 FFE3 212B 2460 5F45 5F45 FA19 FA19 000D 000A FFFD 0073 E2F9 501E FFFD 87BA FFFD
Aa1アガA1アガ亜\¥ ̄Å①彅彅神神
�s倞�螺�

java.nio.file.Files クラスを使った読み書き。

java.nio.file.Files クラスにはファイル操作のための便利なメソッドがいくつも定義されていますが、エラー処理が不十分なため、符号化方式が UTF-8 で、ほとんどエラーが発生しないようなケースでのみ使用した方が良さそうです。

1-5. Unicode 正規化

java.text.Normalizer クラスを使用すると、Unicode 正規化の各正規化形式へ変換することができます。

import java.text.Normalizer;
import java.text.Normalizer.Form;

// ...

    static void testNormalize() {

        String text = "Aa1アガA1アガ亜\u00A5¥ ̄Å①彅\r\n"
                + "\uD842\uDF9F\uD84D\uDD94神神\uFE00カ\u309A\u02E9\u02E5";

        System.out.println("Text:");
        System.out.println(toHexString(text));
        System.out.println(text);

        String nfd = Normalizer.normalize(text, Form.NFD);
        System.out.println("NFD:");
        System.out.println(toHexString(nfd));
        System.out.println(nfd);

        String nfc = Normalizer.normalize(text, Form.NFC);
        System.out.println("NFC:");
        System.out.println(toHexString(nfc));
        System.out.println(nfc);

        String nfkd = Normalizer.normalize(text, Form.NFKD);
        System.out.println("NFKD:");
        System.out.println(toHexString(nfkd));
        System.out.println(nfkd);

        String nfkc = Normalizer.normalize(text, Form.NFKC);
        System.out.println("NFKC:");
        System.out.println(toHexString(nfkc));
        System.out.println(nfkc);
    }

実行結果:

Text:
0041 0061 0031 FF71 FF76 FF9E FF21 FF11 30A2 30AC 4E9C 00A5 FFE5 FFE3 212B 2460 5F45 000D 000A D842 DF9F D84D DD94 FA19 795E FE00 30AB 309A 02E9 02E5
Aa1アガA1アガ亜¥¥ ̄Å①彅
𠮟𣖔神神︀カ゚˩˥
NFD:
0041 0061 0031 FF71 FF76 FF9E FF21 FF11 30A2 30AB 3099 4E9C 00A5 FFE5 FFE3 0041 030A 2460 5F45 000D 000A D842 DF9F D84D DD94 795E 795E FE00 30AB 309A 02E9 02E5
Aa1アガA1アガ亜¥¥ ̄Å①彅 ← 「ガ」が分解
𠮟𣖔神神︀カ゚˩˥ ← 「Å」「神」が別の文字に変換 ×
NFC:
0041 0061 0031 FF71 FF76 FF9E FF21 FF11 30A2 30AC 4E9C 00A5 FFE5 FFE3 00C5 2460 5F45 000D 000A D842 DF9F D84D DD94 795E 795E FE00 30AB 309A 02E9 02E5
Aa1アガA1アガ亜¥¥ ̄Å①彅
𠮟𣖔神神︀カ゚˩˥ ← 「Å」「神」が別の文字に変換 ×
NFKD:
0041 0061 0031 30A2 30AB 3099 0041 0031 30A2 30AB 3099 4E9C 00A5 00A5 0020 0304 0041 030A 0031 5F45 000D 000A D842 DF9F D84D DD94 795E 795E FE00 30AB 309A 02E9 02E5
Aa1アガA1アガ亜¥¥ ̄Å1彅 ← 「ガ」が分解、半角片仮名、全角英数字が正規化
𠮟𣖔神神︀カ゚˩˥ ← 「Å」「神」が別の文字に変換 ×
NFKC:
0041 0061 0031 30A2 30AC 0041 0031 30A2 30AC 4E9C 00A5 00A5 0020 0304 00C5 0031 5F45 000D 000A D842 DF9F D84D DD94 795E 795E FE00 30AB 309A 02E9 02E5
Aa1アガA1アガ亜¥¥ ̄Å1彅 ← 半角片仮名、全角英数字が正規化
𠮟𣖔神神︀カ゚˩˥ ← 「Å」「神」が別の文字に変換 ×

NFD、NFKD では、合成済み文字「ガ (30AC)」が結合文字列「カ+゛(30AB 3099)」に分解されています。
NFKC では、半角片仮名「ガ (FF76 FF9E)」が全角片仮名「ガ (30AC)」に、全角英数字「A (FF21)」「1 (FF11)」が半角英数字「A (0041)」「1 (0031)」に、それぞれ変換されています。 また、いずれの正規化形式でも、「Å (212B)」が「Å (00C5 または 0041 030A)」に、「神 (FA19)」が「神 (795E)」に変換されてしまい、元の情報が維持できません。この問題は、「3. よくある問題と対策」で考察します。

2. システム開発における注意点

本章では、具体的なシステム開発の例を用いて、文字データ、文字コードの設計を試行してみたいと思います。

2-1. ケーススタディ

とあるウェブシステムの開発に携わることになりました。利用者の多くは日本人ですが、日本語の範囲内で多くの文字種が扱えるようにしたいと考えています。
また、当該システムは PC ベースの他システムおよびメインフレームと接続する必要があります。各システムで利用可能な文字は表のとおりです。

ケーススタディ

システム データ項目 利用可能な文字
他システム フリガナ JIS X 0208 片仮名 (全角片仮名)
その他 マイクロソフト標準キャラクタセット (Windows-31J)
メインフレーム フリガナ EBCDIC カナ文字拡張 (英小文字不可)
その他 IBM漢字 (JIS X 0208、IBM拡張文字)
共通 口座名等 0123456789 (数字)
ABCDEFGHIJKLMNOPQRSTUVWXYZ (英大文字)
アイウエオ カキクケコ サシスセソ タチツテト ナニヌネノ ハヒフヘホ マミムメモ ヤユヨ ラリルレロ ワン (カナ)
゙ (濁点)、゚ (半濁点)
- (ハイフン)、( ) (丸括弧)、. (ピリオド)、␣ (スペース)
EDI情報等 上記に加えて、ヲ、¥ (円記号)、「 」 (かぎ括弧)、/ (スラッシュ)

2-2. データ形式の設計

さて、本システムにおけるデータ形式の設計ですが、ケーススタディの要求、OS・ミドルウェア・プログラミング言語の対応状況等から、基本は Unicode ベースになると思います。扱う言語が日本語である点と、他システム連携を考慮して、文字集合は、マイクロソフト標準キャラクタセット (CommonJ)、JIS X 0213、JIS X 0212、+α としました。+α の部分は要件定義で詳細化します。

記録形式

データの記録形式は、DBMS の選定にもよりますが、とある RDBMS を採用し、文字コードは Unicode をすべて格納可能な UTF-8 としました。
Unicode の重複収録文字については、原則として英数字は半角、片仮名は全角へ寄せたいと考えています。
口座名等の特殊な形式については、他システムにそのまま渡せる形式で保持するか、一般的な形式で保持し、他システムに渡す際に変換するか、方法がいくつかありますが、他の用途で使用することがなければ、トラブルを防ぐ意味で、そのまま渡せる形式で保持しようと考えています。

転送形式

ウェブアプリ、ウェブ API で使用するデータ形式は、OS・ブラウザの対応状況、普及率、拡張性を考慮すれば、UTF-8 一択でしょう。最新の HTML 標準でも UTF-8 が推奨されており、全世界の90%以上のウェブサイトが UTF-8 を採用との報告もあります。他システム連携のデータ形式は各システム指定の文字コードにしましたが、状況次第ではこちらも UTF-8 にするかもしれません。

データ形式の設計

2-3. データ入出力時の処理

システムに対してデータを入出力する際、エンコード/デコード、入力チェック、形式変換などの処理が一般に行われます。本節では、これらの処理の設計について考察したいと思います。

入力チェック vs 入力形式変換

入力画面で「半角で入力してください」「全角で入力してください」と指定している項目をときどき見かけます。私は、「切り替えや変換が面倒だな」と思ったり、IME が変な変換の癖を覚えてしまってイラっとすることがありますが、みなさんはどうでしょう? 半角・全角変換、大文字・小文字変換、平仮名・片仮名変換のようにシステムで一意に変換可能なものは、システム内の入力形式変換で変換してあげた方がユーザビリティは高いと思います。IME で入力文字が制限できればそれもよいと思います。
ケーススタディであげた「口座名」であれば、英字の小文字・大文字変換 (a → A)、片仮名の小文字・大文字変換 (キャ → キヤ)、長音からハイフンへの変換 (ー → -) なども、システム側で補助した方がよいでしょう。
なお、「利用者が入力したデータをそのまま保存しなければならない」という制約がある場合は、変換後のデータを利用者に確認してもらうような画面遷移にするとよいと思います。

入力チェック vs 入力形式変換

形式変換前チェック vs 形式変換後チェック

入力データの形式変換を行う場合、入力チェックを変換前に行うか、変換後に行うかよく悩みます。
変換前にチェックを行った方が、エラーの発見が早く、エラー発生時に適切なメッセージを返しやすいのですが、変換を考慮して広めに許容する必要があります。逆に、変換後にチェックを行う場合、チェックルールが素直に書けますが、エラー発生時のメッセージをそのまま返すと利用者に分かりにくく、エラーメッセージの変換が必要になる場合があります。どちらがよいか一概には言えませんが、個人的には入力チェックは形式変換の前に行うのが、トータルでシンプルになる気がします。
なお、システムがレイヤーやマイクロサービスなどに分割される場合は、エラー発生時の早期発見と原因特定、被害の拡大防止の観点から、各責任分界点の内側で最低限の形式チェックを行うことも検討した方がよいかもしれません。そのとき発生するエラーは、入力チェックエラーではなくシステムエラー (バグ) として扱うことになると思います。

形式変換前チェック vs 形式変換後チェック

タイミング メリット デメリット
形式変換前チェック ・エラーの発見が早い。
・適切なエラーメッセージが返しやすい。
・変換を考慮してチェックを記述する必要がある。
形式変換後チェック ・チェックルールが素直に書ける。 ・適切なエラーメッセージを返しにくい。

置換文字へ変換 vs 類似文字へ置換

他システムに連携するデータに相手先システムが扱えない文字が含まれる場合、〓 のような置換文字へ一律変換してしまうのが楽ですが、厳密性よりも利便性が求められるデータであれば、類似の文字へ変換することを検討した方がよいかもしれません。例えば、JIS X 0213 固有文字を含む「八神さん」へのメールや郵便物を JIS X 0208 ベースのシステムへ連携して処理する場合、宛先、本文などを「八〓様」とするより「八神様」と出力した方が好ましいと思います。
「2-2. データ形式の設計」でも触れましたが、口座名等の厳密性が求められる項目については、トラブルを防ぐ意味で、出力時に変換するのではなく、そのまま渡せる形式でシステムに保持した方がよいと思います。

置換文字へ変換 vs 類似文字へ置換

3. よくある問題と対策

本章では、システム開発において、文字コードに関して、よくある問題と対策案について解説します。

3-1. 円記号問題

JIS X 0201 制定の際、ASCII の「逆斜線 (\, 0x5C)」、「チルダ (~, 0x7E)」を「円記号 (¥)」、「オーバーライン ( ̄)」に置き換えたことに起因する問題です。

問題点

ASCII、JIS X 0201 の 0x5C について、以下の問題があります。

  • 0x5C が ASCII、EUC-JP 等では「逆斜線 (\)」、JIS X 0201、Shift_JIS 等では「円記号 (¥)」に割り当てられている。
  • Shift_JIS の 0x5C を Unicode の U+00A5 (YEN SIGN) にマッピングした場合、0x5C に特別な機能を持たせている OS やプログラミング言語が不具合を起こす。
    • Windows のディレクトリ区切り文字
    • プログラミング言語のエスケープシーケンス (\n, \t など)
  • Shift_JIS の 0x5C を Unicode の U+005C (REVERSE SOLIDUS) にマッピングした場合、U+005C が環境によって「逆斜線 (\)」で表示されたり、「円記号 (¥)」で表示されたりする。

同様に、ASCII、JIS X 0201 の 0x7E について、以下の問題があります。

  • 0x7E が ASCII、EUC-JP 等では「チルダ (~)」、JIS X 0201、Shift_JIS 等では「オーバーライン ( ̄)」に割り当てられている。
  • Shift_JIS の 0x7E を Unicode の U+203E (OVERLINE) にマッピングした場合、0x7E に特別な機能を持たせている OS やプログラミング言語が不具合を起こす。
    • UNIX 系の OS のホームディレクトリ
    • ビット演算子の NOT、正規表現との比較
  • Shift_JIS の 0x7E を Unicode の U+007E (TILDE) にマッピングした場合、U+007E が環境によって「チルダ (~)」で表示されたり、「オーバーライン ( ̄)」で表示されたりする。

円記号問題

対策案

現時点で取りうる対策はこんな感じかと思います。

  • EUC-JP、Shift_JIS などの従来の符号化方式ではなく、Unicode (UTF-8 等) を使用する。
  • ディレクトリ区切り文字、プログラミング言語のエスケープシーケンスなど、見た目よりも機能の保証が重要な場合は、U+005C、U+007E を使用する。
  • 通常の文章では、U+005C、U+007E を逆斜線、チルダの意味でのみ用いる。
  • 逆斜線、チルダで「表示」されることを重視する場合は全角形 U+FF3C、U+FF5E の使用を検討する
    (ただし、U+FF5E は JIS X 0208 のみ対応の環境では使用不可)。
  • 円記号は U+00A5 または U+FFE5 を用い、U+005C の誤用を避ける。
  • オーバーラインは U+203E または U+FFE3 を用いる。

妥当な変換表

私が考える、ネイティブコード (Shift_JIS、EUC-JP など、従来の文字コードをこう呼ぶことにします) → Unicode、Unicode → ネイティブコードの妥当な変換表です。ラウンドトリップにより、同じ文字へ戻るようにしています。
Shift_JIS の 0x5C、0x7E は、仕様的には U+00A5、U+207E にデコードするのが本来ですが、多くの変換ツールは U+005C、U+007E にデコードしており、私もこの変換が妥当だと思います。なお、JIS X 0201 と明示した場合は、仕様本来の U+00A5、U+207E にデコードすることにします。
「オーバーライン (‾, U+203E)」の全角形として、「全角マクロン ( ̄, U+FFE3)」を使用するため、「マクロン (¯, U+00AF)」も参考として掲載しました。

(表は横にスクロールしてご覧ください)

JIS X
0201
EUC-JP Shift_JIS Windows
-31J
Shift_JIS
-2004
文字 Unicode JIS X
0201
EUC-JP Shift_JIS
(Windows-31J)
Shift_JIS
-2004
5C 5C 5C 5C \ U+005C 5C 5C 5C 5C
A1C0 815F 815F 815F U+FF3C 5C A1C0 815F 815F
5C ¥ U+00A5 5C A1EF 818F 818F
A1EF 818F 818F 818F U+FFE5 5C A1EF 818F 818F
7E 7E 7E 7E ~ U+007E 7E 7E 7E 7E
8FA2B7 8160 81B0 U+FF5E 7E 8FA2B7 8160 81B0
A1C1 8160 8160 U+301C 7E A1C1 8160 8160
7E U+203E 7E A1B1 8150 8150
A1B1 8150 8150 8150 U+FFE3 7E A1B1 8150 8150
8FA2B4 854A ¯ U+00AF 7E 8FA2B4 8150 854A


  • ○: ラウンドトリップで同じ文字に戻る。
  • △: ラウンドトリップで似た文字に変換。
  • ▲: ラウンドトリップで異なる文字に変換。
  • 全角チルダ (~) については「3-2. 波ダッシュ問題」も参照。

同じ変換で、Unicode → ネイティブコード → Unicode のラウンドトリップを行ったのが次の表です。この場合も、できるだけ同じ文字へ戻るようになっています。真ん中の JIS X 0201 列の ( ) はエンコードの場合のみ適用する対応です。
多くの変換ツールで U+00A5、U+002E を Shift_JIS にエンコードすると 0x5C、0x7E に変換されるのですが、「円記号 (¥)」「オーバーライン ( ̄)」が「逆斜線 (\)」「チルダ (~)」に変わってしまい、好ましくありません。個人的な意見としては、全角の 0x818F、0x8150 に変換する方がよいと思います。
具体的には、ネイティブコードにエンコードする前に、U+00A5 を U+FFE5 に、U+203E を U+FFE3 に変換します。Java であれば、CharsetEncoder をカスタマイズして、変換処理を組み込むことも可能です。
なお、JIS X 0201 の場合は、全角が使えないので、0x5C、0x7E にエンコードしています。

(表は横にスクロールしてご覧ください)

文字 Unicode JIS X
0201
EUC-JP Shift_JIS Windows
-31J
Shift_JIS
-2004
文字 Unicode
\ U+005C (5C) 5C 5C 5C 5C \ U+005C ○
U+FF3C (5C) A1C0 815F 815F 815F U+FF3C ○
¥ U+00A5 5C ¥ U+00A5 ○
A1EF 818F 818F 818F U+FFE5
U+FFE5 (5C) A1EF 818F 818F 818F U+FFE5 ○
~ U+007E (7E) 7E 7E 7E 7E ~ U+007E ○
U+FF5E (7E) 8FA2B7 (8160) 8160 81B0 U+FF5E ○
U+301C (7E) A1C1 8160 (8160) 8160 U+301C ○
U+203E 7E U+203E ○
A1B1 8150 8150 8150 U+FFE3
U+FFE3 (7E) A1B1 8150 8150 8150 U+FFE3 ○
¯ U+00AF (7E) 8150 8150 U+FFE3
8FA2B4 854A ¯ U+00AF ○


  • ○: ラウンドトリップで同じ文字に戻る。
  • △: ラウンドトリップで似た文字に変換。
  • 全角チルダ (~) については「3-2. 波ダッシュ問題」も参照。

3-2. 波ダッシュ問題

本来同じ符号化方式のはずの Shift_JIS と Windows-31J で、Unicode との変換に差異があることに起因する問題です。

(表は横にスクロールしてご覧ください)

区点 SJIS 名称 文字 Shift_JIS でデコード Windows-31J でデコード
1-29 815C EM DASH U+2014
(EM DASH)
U+2015
(HORIZONTAL BAR)
1-33 8160 WAVE DASH U+301C
(WAVE DASH)
U+FF5E
(FULLWIDTH TILDE)
1-34 8161 DOUBLE VERTICAL LINE U+2016
(DOUBLE VERTICAL LINE)
U+2225
(PARALLEL TO)
1-61 817C MINUS SIGN U+2212
(MINUS SIGN)
U+FF0D
(FULLWIDTH HYPHEN-MINUS)
1-81 8191 CENT SIGN ¢ U+00A2
(CENT SIGN)
U+FFE0
(FULLWIDTH CENT SIGN)
1-82 8192 POUND SIGN £ U+00A3
(POUND SIGN)
U+FFE1
(FULLWIDTH POUND SIGN)
2-44 81CA NOT SIGN ¬ U+00AC
(NOT SIGN)
U+FFE2
(FULLWIDTH NOT SIGN)

問題点

変換の差異のため、以下の問題が発生します。

ネイティブコード → (Shift_JIS でデコード) → Unicode → (Windows-31J でエンコード) → ネイティブコードのような変換を行うと、これらの文字はエラーとなり、「?」に置換されたり、例外が発生します。

  • 例) 8160 → (Shift_JIS でデコード) → U+301C → (Windows-31J でエンコード) → エラー

また、Unicode → (Shift_JIS でエンコード) → ネイティブコード → (Windows-31J でデコード) → Unicode のような変換を行うと、異なる符号位置に変換されてしまいます。

  • 例) U+301C → (Shift_JIS でエンコード) → 8160 → (Windows-31J でデコード) → U+FF5E

対策案

最初の問題については、どちらの Unicode も、Shift_JIS でも Windows-31J でも同じネイティブコードへエンコードされるように修正します。例えば、U+301C、U+FF3E どちらも 8160 へ変換します。
2番目の問題は、現状を許容するか、Shift_JIS でも Windows-31J でも同じ Unicode へデコードされるように、一方へ寄せます。

妥当な変換表

私が考える、ネイティブコード → Unicode、Unicode → ネイティブコードの妥当な変換表です。ラウンドトリップにより、できるだけ同じ文字へ戻るようにしています。
対策案で書いたように、例えば、U+301C、U+FF5E どちらの Unicode も、Shift_JIS でも Windows-31J でも同じネイティブコード 8160 へエンコードします。
具体的には、Shift_JIS でエンコードする前に U+FF5E を U+301C に変換し、Windows-31 でエンコードする前に U+301C を U+FF5E に変換します。Java であれば、CharsetEncoder をカスタマイズして、変換処理を組み込むことも可能です。

(表は横にスクロールしてご覧ください)

EUC-JP Shift_JIS Windows
-31J
Shift_JIS
-2004
文字 Unicode EUC-JP Shift_JIS
(Windows-31J)
Shift_JIS
-2004
A1BD 815C 815C U+2014 A1BD 815C 815C
815C U+2015 A1BD 815C 815C
A1C1 8160 8160 U+301C A1C1 8160 8160
8FA2B7 8160 81B0 U+FF5E 8FA2B7 8160 81B0
A1C2 8161 8161 U+2016 A1C2 8161 8161
8161 81D2 U+2225 A1C2 8161 81D2
A1DD 817C 817C U+2212 A1DD 817C 817C
817C 81AF U+FF0D A1DD 817C 81AF
A1F1 8191 8191 ¢ U+00A2 A1F1 8191 8191
8191 U+FFE0 A1F1 8191 8191
A1F2 8192 8192 £ U+00A3 A1F2 8192 8192
8192 U+FFE1 A1F2 8192 8192
A2CC 81CA 81CA ¬ U+00AC A2CC 81CA 81CA
81CA U+FFE2 A2CC 81CA 81CA


  • ○: ラウンドトリップで同じ文字に戻る。ただし、符号可能式を変えた場合は似た文字へ変換もあり。

同じ変換で、Unicode → ネイティブコード → Unicode のラウンドトリップを行ったのが次の表です。この場合も、できるだけ同じ文字へ戻るようになっています。
真ん中4列の ( ) はエンコードの場合のみ適用する対応です。右の Unicode 列の ( ) は Shift_JIS、Windows-31J でデコードするとき、片寄せする場合の対応です。U+2015、U+FF5E、U+2225、U+FF0D を U+2014、U+301C、U+2016、U+2212 へ片寄せするのはよいと思いますが、U+00A2、U+00A3、U+00AC と U+FFE0、U+FFE1、U+FFE2 については、EBCDIC のように半角の ¢、£、¬ が存在する文字集合を考慮すると後者へ寄せるもありかなと思います。対策案で書いたように、現状のラウンドトリップを優先するなら、デコード時の変換はなしでもよいかもしれません。

(表は横にスクロールしてご覧ください)

文字 Unicode EUC-JP Shift_JIS Windows-31J Shift_JIS-2004 文字 Unicode
U+2014 A1BD 815C (815C) 815C U+2014 ○
U+2015 (A1BD) (815C) 815C (815C) U+2015 ○
(U+2014)
U+301C A1C1 8160 (8160) 8160 U+301C ○
U+FF5E 8FA2B7 (8160) 8160 81B0 U+FF5E ○
(U+301C)
U+2016 A1C2 8161 (8161) 8161 U+2016 ○
U+2225 (A1C2) (8161) 8161 81D2 U+2225 ○
(U+2016)
U+2212 A1DD 817C (817C) 817C U+2212 ○
U+FF0D (A1DD) (817C) 817C 81AF U+FF0D ○
(U+2212)
¢ U+00A2 A1F1 8191 (8191) 8191 ¢ U+00A2 ○
(U+FFE0)
U+FFE0 (A1F1) (8191) 8191 (8191) U+FFE0 ○
~~(U+00A2)~~
£ U+00A3 A1F2 8192 (8192) 8192 £ U+00A3 ○
(U+FFE1)
U+FFE1 (A1F2) (8192) 8192 (8192) U+FFE1 ○
~~(U+00A3)~~
¬ U+00AC A2CC 81CA (81CA) 81CA ¬ U+00AC ○
(U+FFE2)
U+FFE2 (A2CC) (81CA) 81CA (81CA) U+FFE2 ○
~~(U+00AC)~~


  • ○: ラウンドトリップで同じ文字に戻る。ただし、符号可能式を変えた場合は似た文字へ変換もあり。

3-3. 半角・全角

ISO-2022-JP、EUC-JP、Shift_JIS などの符号化方式、Unicode の文字集合では、いわゆる「半角文字」「全角文字」が重複収録されています。これらを一方へ寄せることを考えます。
方法としては、a) ラテン文字を半角、片仮名を全角へ寄せる方法 (本節ではこれを「正規化」と呼ぶことにします、「Unicode 正規化」と似ていますが全く同じではありません)、b) ラテン文字・片仮名とも半角へ寄せる、c) ラテン文字・片仮名とも全角へ寄せる、の3通りが考えられます。これらを順に考察していきます。
なお、前回説明しましたが、ASCII のいくつかの記号について、JIS X 0208 では対応する全角文字が1対1で定義されていません。

(表は横にスクロールしてご覧ください)

半角 全角
ASCII 文字 Unicode JIS X
0208
W31J JIS X
0213
文字 Unicode
22 U+0022
(QUOTATION MARK)
115-24
(92-94)
1-2-16 U+FF02
(FULLWIDTH QUOTATION MARK)
1-15 1-15 1-1-15 ¨ U+00A8
(DIAERESIS)
1-40 1-40 1-1-40 U+201C
(LEFT DOUBLE QUOTATION MARK)
1-41 1-41 1-1-41 U+201D
(RIGHT DOUBLE QUOTATION MARK)
1-77 1-77 1-1-77 U+2033
(DOUBLE PRIME)
27 U+0027
(APOSTROPHE)
115-23
(92-93)
1-2-15 U+FF07
(FULLWIDTH APOSTROPHE)
1-13 1-13 1-1-13 ´ U+00B4
(ACUTE ACCENT)
1-38 1-38 1-1-38 U+2018
(LEFT SINGLE QUOTATION MARK)
1-39 1-39 1-1-39 U+2019
(RIGHT SINGLE QUOTATION MARK)
1-76 1-76 1-1-76 U+2032
(PRIME)
2D - U+002D
(HYPHEN-MINUS)
1-61 1-2-17 U+FF0D
(FULLWIDTH HYPHEN-MINUS)
1-30 1-30 1-1-30 U+2010
(HYPHEN)
1-61 1-1-61 U+2212
(MINUS SIGN)
7E ~ U+007E
(TILDE)
1-33 1-2-18 U+FF5E
(FULLWIDTH TILDE)
1-33 1-1-33 U+301C
(WAVE DASH)

正規化

記号については、むやみに全角・半角変換を行うと、円記号問題や波ダッシュ問題に関連して問題が発生したり、表示上の見た目が大幅に変わることもあるため、a1) 英数字のみ半角へ寄せるパターン、a2) 記号を含むすべての全角形を半角へ寄せるパターンの2つを考えてみました。なお、半角片仮名は、推奨されないケースが多いので、どちらの場合も全角へ寄せます。
a1 は通常の入力データを記録する前の変換、a2 は検索インデックスを作成する際の変換、といった使い方を想定しています。

変換範囲 説明 用途
a1 英数字を半角へ、片仮名を全角へ寄せる。 円記号問題、波ダッシュ問題、表示上の見た目等あるので、英数字のみ半角へ寄せる。 通常のデータ記録時など
a2 全角形を半角へ、半角形を全角へ寄せる。 すべての全角形を半角へ寄せる。 検索インデックス作成時など

具体的な変換イメージは以下の通りです。

元の文字 a1 a2 備考
文字 Unicode 文字 Unicode 文字 Unicode
  U+3000   (U+3000) (SP) U+0020 スペース
U+FF11 1 U+0031 1 U+0031 数字
U+FF21 A U+0041 A U+0041 英大文字
U+FF41 a U+0061 a U+0061 英小文字
U+FF0C (U+FF0C) , U+002C コンマ
U+FF0E (U+FF0E) . U+002E ピリオド
U+FF02 (U+FF02) U+0022 二重引用符
U+FF07 (U+FF07) U+0027 アポストロフィ
U+FF08 (U+FF08) ( U+0028 始め丸括弧
U+FF09 (U+FF09) ) U+0029 終わり丸括弧
U+FF3B (U+FF3B) [ U+005B 始め角括弧
U+FF3D (U+FF3D) ] U+005D 終わり角括弧
U+FF0B (U+FF0B) + U+002B プラス
U+FF0D (U+FF0D) - U+002D ハイフン・マイナス
U+FF0A (U+FF0A) * U+002A アスタリスク
U+FF0F (U+FF0F) / U+002F 斜線
U+FF3C (U+FF3C) \ U+005C 逆斜線
U+FF5E (U+FF5E) ~ U+007E チルダ
U+FFE5 (U+FFE5) ¥ U+00A5 円記号
U+FFE3 (U+FFE3) U+203E オーバーライン
U+FF71 U+30A2 U+30A2 片仮名
ガ U+FF76 U+FF9E U+30AC U+30AC 片仮名 (濁音)
カ゚ U+FF76 U+FF9F カ゚ U+30AB U+309A カ゚ U+30AB U+309A 片仮名 (結合文字列)
パ U+FF8A U+FF9F U+30D1 U+30D1 片仮名 (半濁音)
キャ U+FF77 U+FF6C キャ U+30AD U+30E3 キャ U+30AD U+30E3 片仮名 (拗音)
U+FF66 U+30F2 U+30F2 片仮名 (ヲ)
ヴ U+FF73 U+FF9E U+30F4 U+30F4 片仮名 (ヴ)
U+FF9E U+3099 U+3099 濁点
U+FF9F U+309A U+309A 半濁点
U+FF70 U+30FC U+30FC 長音記号
U+FF65 U+30FB U+30FB 中点
U+FF64 U+3001 U+3001 読点
U+FF61 U+3002 U+3002 句点
U+FF62 U+300C U+300C 始めかぎ括弧
U+FF63 U+300D U+300D 終わりかぎ括弧
U+FFE0 (U+FFE0) ¢ U+00A2 セント記号
U+FFE1 (U+FFE1) £ U+00A3 ポンド記号
U+FFE2 (U+FFE2) ¬ U+00AC 否定
U+FFE4 (U+FFE4) ¦ U+00A6 破断線

半角変換

ASCII や JIS X 0201 などの1バイト項目にデータを格納するための変換です。半角・全角が直接対応する文字以外にも、左右引用符 (“, ”, ‘, ’)、ハイフン (‐)、マイナス (−) 等は意味的に半角変換してよいと思います。銀行とのデータ連携では JIS X 0201 より使用可能な文字が限られるので、英小文字から英大文字への変換、小書き片仮名から大文字の通常の片仮名への変換、長音からハイフンへの変換などを用意すると便利でしょう。

変換範囲 説明 用途
b1 直接対応する文字+α 左右引用符、ハイフン、マイナス等も変換。 古いシステムとのデータ連携
b2 全銀使用可能文字 英小文字、小書き片仮名、長音 (ー) 等も変換。 銀行とのデータ連携

具体的な変換イメージは以下の通りです。

元の文字 b1 b2 備考
文字 Unicode 文字 Unicode 文字 Unicode
  U+3000 (SP) U+0020 (SP) U+0020 スペース
U+FF11 1 U+0031 1 U+0031 数字
U+FF21 A U+0041 A U+0041 英大文字
U+FF41 a U+0061 A U+0041 英小文字
U+FF0C , U+002C コンマ
U+FF0E . U+002E . U+002E ピリオド
U+FF02 U+0022 二重引用符
U+201C U+0022 左二重引用符
U+201D U+0022 右二重引用符
U+FF07 U+0027 アポストロフィ
U+2018 U+0027 左単一引用符
U+2019 U+0027 右単一引用符
U+FF08 ( U+0028 ( U+0028 始め丸括弧
U+FF09 ) U+0029 ) U+0029 終わり丸括弧
U+FF3B [ U+005B 始め角括弧
U+FF3D ] U+005D 終わり角括弧
U+FF0B + U+002B プラス
U+FF0D - U+002D - U+002D ハイフン・マイナス
U+2010 - U+002D - U+002D ハイフン
U+2212 - U+002D - U+002D マイナス
U+FF0A * U+002A アスタリスク
U+FF0F / U+002F / (U+002F) 斜線
U+FF3C \ U+005C 逆斜線
U+FF5E ~ U+007E チルダ
U+301C ~ U+007E 波ダッシュ
U+FFE5 ¥ U+00A5 ¥ (U+00A5) 円記号
U+FFE3 U+203E オーバーライン
U+3042 U+FF71 U+FF71 平仮名
U+30A2 U+FF71 U+FF71 片仮名
U+304C ガ U+FF76 U+FF9E ガ U+FF76 U+FF9E 平仮名 (濁音)
U+30AC ガ U+FF76 U+FF9E ガ U+FF76 U+FF9E 片仮名 (濁音)
か゚ U+304B U+309A カ゚ U+FF76 U+FF9F カ゚ U+FF76 U+FF9F 平仮名 (結合文字列)
カ゚ U+30AB U+309A カ゚ U+FF76 U+FF9F カ゚ U+FF76 U+FF9F 片仮名 (結合文字列)
U+3071 パ U+FF8A U+FF9F パ U+FF8A U+FF9F 平仮名 (半濁音)
U+30D1 パ U+FF8A U+FF9F パ U+FF8A U+FF9F 片仮名 (半濁音)
きゃ U+304D U+3083 キャ U+FF77 U+FF6C キヤ U+FF77 U+FF94 平仮名 (拗音)
キャ U+30AD U+30E3 キャ U+FF77 U+FF6C キヤ U+FF77 U+FF94 片仮名 (拗音)
U+3092 U+FF66 (U+FF75) 平仮名 (を)
U+30F2 U+FF66 (U+FF75) 片仮名 (ヲ)
U;3094 ヴ U+FF73 U+FF9E ヴ U+FF73 U+FF9E 平仮名 (ゔ)
U+30F4 ヴ U+FF73 U+FF9E ヴ U+FF73 U+FF9E 片仮名 (ヴ)
U+3099 U+FF9E U+FF9E 濁点 (結合文字)
U+309B U+FF9E U+FF9E 濁点
U+309A U+FF9F U+FF9F 半濁点 (結合文字)
U+309C U+FF9F U+FF9F 半濁点
U+30FC U+FF70 - U+002D 長音記号
U+30FB U+FF65 中点
U+3001 U+FF64 読点
U+3002 U+FF61 句点
U+300C U+FF62 (U+FF62) 始めかぎ括弧
U+300D U+FF63 (U+FF63) 終わりかぎ括弧
U+FFE0 ¢ U+00A2 セント記号
U+FFE1 £ U+00A3 ポンド記号
U+FFE2 ¬ U+00AC 否定
U+FFE4 ¦ U+00A6 破断線
  • b2 列の ( ) は、EDI情報等でのみ使用可能な文字。

全角変換

日本語のデータを全角項目に格納するための変換です。使える文字集合に応じて、変換先を若干変えています。

変換範囲 説明 用途
c1 JIS X 0208 (JIS97) 漢字用7ビット符号で符号化可能。 EDI の全角項目
c2 Windows-31J Windows-31J の2バイト文字。 全角項目
c3 JIS X 0213 (JIS2004) 漢字用8ビット符号で符号化可能。 全角項目

具体的な変換イメージは以下の通りです。
例えば、JIS X 0208 では、左右が曖昧な全角引用符が定義されていないため、右引用符へ変換しています。

(表は横にスクロールしてご覧ください)

元の文字 c1 c2 c3 備考
文字 Unicode 文字 Unicode 文字 Unicode 文字 Unicode
(SP) U+0020   U+3000   U+3000   U+3000 スペース
1 U+0031 U+FF11 U+FF11 U+FF11 数字
A U+0041 U+FF21 U+FF21 U+FF21 英大文字
a U+0061 U+FF41 U+FF41 U+FF41 英小文字
, U+002C U+FF0C U+FF0C U+FF0C コンマ
. U+002E U+FF0E U+FF0E U+FF0E ピリオド
U+0022 U+201D U+FF02 U+FF02 二重引用符
U+0027 U+2019 U+FF07 U+FF07 アポストロフィ
( U+0028 U+FF08 U+FF08 U+FF08 始め丸括弧
) U+0029 U+FF09 U+FF09 U+FF09 終わり丸括弧
[ U+005B U+FF3B U+FF3B U+FF3B 始め角括弧
] U+005D U+FF3D U+FF3D U+FF3D 終わり角括弧
+ U+002B U+FF0B U+FF0B U+FF0B プラス
- U+002D U+2212 U+FF0D U+FF0D ハイフン・マイナス
* U+002A U+FF0A U+FF0A U+FF0A アスタリスク
/ U+002F U+FF0F U+FF0F U+FF0F 斜線
\ U+005C U+FF3C U+FF3C U+FF3C 逆斜線
~ U+007E U+301C U+FF5E U+FF5E チルダ
¥ U+00A5 U+FFE5 U+FFE5 U+FFE5 円記号
U+203E U+FFE3 U+FFE3 U+FFE3 オーバーライン
¯ U+00AF U+FFE3 U+FFE3 ¯ (U+00AF) マクロン
U+FF71 U+30A2 U+30A2 U+30A2 片仮名
ガ U+FF76 U+FF9E U+30AC U+30AC U+30AC 片仮名 (濁音)
カ゚ U+FF76 U+FF9F カ゜ U+30AB U+309C カ゜ U+30AB U+309C カ゚ U+30AB U+309A 片仮名 (結合文字列)
パ U+FF8A U+FF9F U+30D1 U+30D1 U+30D1 片仮名 (半濁音)
キャ U+FF77 U+FF6C キャ U+30AD U+30E3 キャ U+30AD U+30E3 キャ U+30AD U+30E3 片仮名 (拗音)
U+FF66 U+30F2 U+30F2 U+30F2 片仮名 (ヲ)
ヴ U+FF73 U+FF9E U+30F4 U+30F4 U+30F4 片仮名 (ヴ)
U+FF9E U+309B U+309B U+309B 濁点
U+FF9F U+309C U+309C U+309C 半濁点
U+FF70 U+30FC U+30FC U+30FC 長音記号
U+FF65 U+30FB U+30FB U+30FB 中点
U+FF64 U+3001 U+3001 U+3001 読点
U+FF61 U+3002 U+3002 U+3002 句点
U+FF62 U+300C U+300C U+300C 始めかぎ括弧
U+FF63 U+300D U+300D U+300D 終わりかぎ括弧
¢ U+00A2 ¢ (U+00A2) U+FFE0 ¢ (U+00A2) セント記号
£ U+00A3 £ (U+00A3) U+FFE1 £ (U+00A3) ポンド記号
¬ U+00AC ¬ (U+00AC) U+FFE2 ¬ (U+00AC) 否定
¦ U+00A6 U+FFE4 ¦ (U+00A6) 破断線

3-4. 異体字

異体字の変換も2パターン考えてみました。要件次第ですが、必要な情報が欠落する可能性があるので、通常のデータでの異体字の統合は慎重に行った方がよいと思います。

変換範囲 説明 用途
a1 ・JIS X 0213 の包摂基準を適用する。
・表外漢字字体表で簡易慣用字体に未掲載の文字は印刷標準字体へ変換する。
標準規格で包摂される異体字のみ変換。 通常のデータなど
a2 ・常用漢字、人名用漢字は新字体に統一する。
・それ以外の表外漢字は印刷標準字体に統一する。
新字体・旧字体、印刷標準字体・簡易慣用字体は一方へ寄せる。 検索用インデックスなど

具体的な変換イメージは以下の通りです。
例えば、JIS X 0213 には ISO/IEC 8859-1 の文字が収録されましたが、「µ (マイクロ)」だけ収録されておらず、「μ (ギリシャ文字のミュー)」に包摂されるため、a1 で変換してます。同じく、JIS X 0213 の13区に NEC特殊文字 が収録されましたが、「∑ (総和)」は収録されておらず、「Σ (ギリシャ文字のシグマ)」に包摂されるため、a1 で変換しています。
表外漢字字体表において、「鴎」は簡易慣用字体として掲載されているため a1 では変換しませんが、「騨」は掲載されていないため a1 で変換しています。「髙 (はしごだか)」は「高」に包摂されるため、a1 で変換しています。

元の文字 a1 a2 備考
文字 Unicode 文字 Unicode 文字 Unicode
µ U+00B5 μ U+03BC μ U+03BC 包摂 (利用不可)
U+2211 Σ U+03A3 Σ U+03A3 包摂 (IBM拡張文字)
U+4E9E (U+4E9E) U+4E9C 常用漢字
U+582F (U+582F) U+5C2D 人名用漢字
U+FA19 (U+FA19) U+795E CJK互換漢字
U+9D0E (U+9D0E) U+9DD7 表外漢字字体表
U+9A28 U+9A52 U+9A52 表外漢字字体表
U+9AD9 U+9AD8 U+9AD8 包摂 (IBM拡張文字)

3-5. 合成・分解、CJK互換漢字、異体字シーケンス

合成・分解は、分解側へ揃えた方が統一感はありますが、文字数の節約、エンコード/デコードの対応状況から、合成可能な結合文字列は合成側へ寄せます。
ソフトウェアの異体字シーケンス対応状況がまだまだなので、通常のデータでは当面「CJK互換漢字」を使おうと思います。検索インデックスでは正規化し、異体字セレクタは除去します。

変換範囲 説明 用途
共通 ・合成可能な結合文字列は合成する。 文字数の節約、エンコード/デコードの対応状況から。
a1 ・CJK互換漢字に対応する異体字シーケンスはCJK互換漢字へ変換する。 ソフトウェアの異体字セレクタ対応状況が低いため。 通常のデータなど
a2 ・CJK互換漢字は正規化する。
・異体字セレクタは除去する。
読み方、使い方、意味が等しい異体字は一方へ寄せる。 検索用インデックスなど

具体的な変換イメージは以下の通りです。

元の文字 a1 a2 備考
文字 Unicode 文字 Unicode 文字 Unicode
ガ U+30AB U+3099 U+30AC U+30AC 片仮名 (結合文字列)
U+FA19 (U+FA19) U+795E CJK互換漢字
(神︀) U+795E U+FE00 U+FA19 U+795E 異体字シーケンス

3-6. Unicode 正規化

前回および 1-5 で説明したように、Unicode を単純に正規化すると、意図しない変換まで行われてしまいます。これを回避するために、文字列全体を正規化せずに、正規化可能な部分だけ抽出して正規化をかけます。

正規化の対象から指定された文字を除外する

以下の例では、変換したくない文字を指定し、それを除外して正規化 (合成・分解) を行っています。

    // 正規化の対象から除く文字のパターン。
    static final Pattern EXCLUSION_PATTERN = Pattern.compile(
            // アキュート付きα、ε、オングストローム、CJK互換漢字を除く。
            "[^\\u1F71\\u1F73\\u212B\\uF900-\\uFAFF]+");

    static void testNormalize2() {

        String text = "Aa1アガA1アガ亜\u00A5¥ ̄Å①彅\r\n"
                + "\uD842\uDF9F\uD84D\uDD94神神\uFE00カ\u309A\u02E9\u02E5";

        System.out.println("Text:");
        System.out.println(toHexString(text));
        System.out.println(text);

        String mnfd = replaceAll(text, EXCLUSION_PATTERN,
                s -> Normalizer.normalize(s, Form.NFD));
        System.out.println("Modified NFD:");
        System.out.println(toHexString(mnfd));
        System.out.println(mnfd);

        String mnfc = replaceAll(text, EXCLUSION_PATTERN,
                s -> Normalizer.normalize(s, Form.NFC));
        System.out.println("Modified NFC:");
        System.out.println(toHexString(mnfc));
        System.out.println(mnfc);
    }

    /**
     * 文字列のパターンにマッチした部分を関数の結果で置き換えます。
     *
     * @param s 文字列
     * @param p 正規表現のパターン
     * @param f 変換関数
     * @return 変換後の文字列
     */
    static String replaceAll(CharSequence s, Pattern p, Function<String, String> f) {
        if (s == null || "".equals(s)) {
            return (String) s;
        }
        return p.matcher(s).replaceAll(r -> f.apply(r.group()));
    }

実行結果:

Text:
0041 0061 0031 FF71 FF76 FF9E FF21 FF11 30A2 30AC 4E9C 00A5 FFE5 FFE3 212B 2460 5F45 000D 000A D842 DF9F D84D DD94 FA19 795E FE00 30AB 309A 02E9 02E5
Aa1アガA1アガ亜¥¥ ̄Å①彅
𠮟𣖔神神︀カ゚˩˥
Modified NFD:
0041 0061 0031 FF71 FF76 FF9E FF21 FF11 30A2 30AB 3099 4E9C 00A5 FFE5 FFE3 212B 2460 5F45 000D 000A D842 DF9F D84D DD94 FA19 795E FE00 30AB 309A 02E9 02E5
Aa1アガA1アガ亜¥¥ ̄Å①彅 ← 「ガ」が分解
𠮟𣖔神神︀カ゚˩˥ ← 「Å」「神」の変換なし ○
Modified NFC:
0041 0061 0031 FF71 FF76 FF9E FF21 FF11 30A2 30AC 4E9C 00A5 FFE5 FFE3 212B 2460 5F45 000D 000A D842 DF9F D84D DD94 FA19 795E FE00 30AB 309A 02E9 02E5
Aa1アガA1アガ亜¥¥ ̄Å①彅
𠮟𣖔神神︀カ゚˩˥ ← 「Å」「神」の変換なし ○

正規化の対象を指定された文字に限定する

以下の例では、変換対象の文字を指定して、その部分にだけ正規化 (合成・分解、半角・全角変換) を行っています。

    // 正規化の対象に含める文字のパターン。
    static final Pattern INCLUSION_PATTERN = Pattern.compile(
            // 平仮名・片仮名。
            "[\\u3041-\\u3096\\u3099\\u309A\\u309D\\u309E\\u30A1-\\u30FA\\u30FD\\u30FE"
            // 全角英数字 (記号は含まない)、半角片仮名。
            + "\\uFF10-\\uFF19\\uFF21-\\uFF3A\\uFF41-\\uFF5A\\uFF61-\\uFF9F]+");

    static void testNormalize3() {

        String text = "Aa1アガA1アガ亜\u00A5¥ ̄Å①彅\r\n"
                + "\uD842\uDF9F\uD84D\uDD94神神\uFE00カ\u309A\u02E9\u02E5";

        System.out.println("Text:");
        System.out.println(toHexString(text));
        System.out.println(text);

        String mnfkd = replaceAll(text, INCLUSION_PATTERN,
                s -> Normalizer.normalize(s, Form.NFKD));
        System.out.println("Modified NFKD:");
        System.out.println(toHexString(mnfkd));
        System.out.println(mnfkd);

        String mnfkc = replaceAll(text, INCLUSION_PATTERN,
                s -> Normalizer.normalize(s, Form.NFKC));
        System.out.println("Modified NFKC:");
        System.out.println(toHexString(mnfkc));
        System.out.println(mnfkc);
    }

実行結果:

Text:
0041 0061 0031 FF71 FF76 FF9E FF21 FF11 30A2 30AC 4E9C 00A5 FFE5 FFE3 212B 2460 5F45 000D 000A D842 DF9F D84D DD94 FA19 795E FE00 30AB 309A 02E9 02E5
Aa1アガA1アガ亜¥¥ ̄Å①彅
𠮟𣖔神神︀カ゚˩˥
Modified NFKD:
0041 0061 0031 30A2 30AB 3099 0041 0031 30A2 30AB 3099 4E9C 00A5 FFE5 FFE3 212B 2460 5F45 000D 000A D842 DF9F D84D DD94 FA19 795E FE00 30AB 309A 02E9 02E5
Aa1アガA1アガ亜¥¥ ̄Å①彅 ← 「ガ」が分解、半角片仮名、全角英数字が正規化
𠮟𣖔神神︀カ゚˩˥ ← 「Å」「神」の変換なし ○
Modified NFKC:
0041 0061 0031 30A2 30AC 0041 0031 30A2 30AC 4E9C 00A5 FFE5 FFE3 212B 2460 5F45 000D 000A D842 DF9F D84D DD94 FA19 795E FE00 30AB 309A 02E9 02E5
Aa1アガA1アガ亜¥¥ ̄Å①彅
𠮟𣖔神神︀カ゚˩˥ ← 「Å」「神」の変換なし ○

3-7. 国際化の考慮不足

最近は少なくなりましたが、国際化の考慮不足により ISO-8859-1 の決め打ちでデコードしている欧米のミドルウェアが以前はかなりありました。
この場合、ISO-8859-1 でエンコードして、いったんバイナリに戻してから、本来の符号化方式でデコードし直すと、期待する文字列が取得できます。

国際化の考慮不足

// 国際化の考慮不足で ISO-8859-1 でデコード。
String latin1 = ...
System.out.println("ISO-8859-1:");
System.out.println(toHexString(latin1));
System.out.println(latin1);

// いったんバイナリに戻してから、Windows-31J でデコード。
String w31j = new String(latin1.getBytes(StandardCharsets.ISO_8859_1), WINDOWS_31J);
System.out.println("Windows-31J:");
System.out.println(toHexString(w31j));
System.out.println(w31j);

実行結果:

ISO-8859-1:
0041 0031 00B1 0083 0041 0087 0040 0088 009F 00FA 0067
A1±ƒA‡@ˆŸúg
Windows-31J:
0041 0031 FF71 30A2 2460 4E9C 5F45
A1アア①亜彅

3-8. 改行

環境によって改行を表す文字が異なる問題については、どちらかに統一するか、あまり重要な問題でなければそのままでもよいかもしれません。

対策案
案1 入力時に LF に統一する。
案2 入力時に CR+LF に統一する。
案3 入力されたまま変換しない。

4. まとめと次回の予告

システム開発で必要となる標準規格の話、連載第2回は文字コードの実践編と題して、プログラミングでの扱い方、システム開発において特に注意すべき問題点と対策案を解説しました。

次回は、ローマ字を取り上げる予定です。訓令式、ヘボン式、日本式、いくつかあるローマ字の差異、かな・ローマ字変換を設計・実装する際のポイントなどを説明したいと思います。

A1. Java に関する補足

A1-1. サポートされているエンコーディング (日本語関連)

通常使用するのは、UTF-8、ISO-2022-JP、EUC-JP、Shift_JIS、Windows-31J くらいかと思いますが、改めて調べてみると意外と多くのエンコーディングが定義されていました。場合によっては使えるものがあるかもしれません。ただし、よく分からない仕様のもの (x-MS932_0213) や、ウェブで見つけられる仕様と異なるもの (x-windows-50220、x-windows-50221) があるので注意が必要です。

(表は横にスクロールしてご覧ください)

java.nio API用の正準名 別名 (抜粋) 説明 補足
UTF-8 UTF8 8ビット Unicode (UCS) Transformation Format
CESU-8 CESU8 Unicode CESU-8 サロゲートペアのまま UTF-8 に変換
UTF-16 UTF16, Unicode 16ビット Unicode (UCS) Transformation Format、オプションのバイト順マークによって識別されるバイト順
UTF-16BE 16ビット Unicode (UCS) Transformation Format、ビッグエンディアン・バイト順
UTF-16LE 16ビット Unicode (UCS) Transformation Format、リトルエンディアン・バイト順
UTF-32 UTF32 32ビット Unicode (UCS) Transformation Format、オプションのバイト順マークによって識別されるバイト順
UTF-32BE 32ビット Unicode (UCS) Transformation Format、ビッグエンディアン・バイト順
UTF-32LE 32ビット Unicode (UCS) Transformation Format、リトルエンディアン・バイト順
US-ASCII ASCII American Standard Code for Information Interchange
ISO-8859-1 ISO8859_1, latin1 ISO-8859-1、ラテン・アルファベット No.1
JIS_X0201 JIS X 0201
ISO-2022-JP ISO2022JP ISO 2022 形式の JIS X 0201、0208、日本語 半角片仮名にも対応 (仕様外)
ISO-2022-JP-2 ISO2022JP2 ISO 2022 形式の JIS X 0201、0208、0212、日本語 半角片仮名にも対応 (仕様外)
EUC-JP EUC_JP, eucjp JISX 0201、0208、0212、EUCエンコーディング、日本語
Shift_JIS SJIS Shift-JIS、日本語
Windows-31J MS932, csWindows31J Windows 日本語
x-JISAutoDetect JISAutoDetect Shift-JIS、EUC-JP、ISO 2022 JP の検出および変換 (Unicode への変換のみ)
x-SJIS_0213 Shift_JISX0213 Shift_JIS-2004 相当
x-MS932_0213 Shift_JISX0213 Windows MS932 拡張機能 MS932 の隙間を埋めただけで、文字の採用に論理性がない
x-windows-iso2022jp 拡張ISO-2022-JP (MS932ベース) ISO-2022-JP の亜種、
NEC特殊文字、NEC選定IBM拡張文字に対応
x-windows-50220 Cp50220 Windows Codepage 50220 (7ビット実装) ISO-2022-JP の亜種、
一般的な Cp50221 とマッピングが異なる
x-windows-50221 Cp50221 Windows Codepage 50221 (7ビット実装) ISO-2022-JP の亜種、
一般的な Cp50221 とマッピングが異なる
x-euc-jp-linux EUCJPLINUX JISX 0201、0208、EUCエンコーディング、日本語 EUC-JP の亜種
JIS X 0212 未対応版
x-eucJP-Open EUCJPSolaris JISX 0201、0208、0212、EUCエンコーディング、日本語 EUC-JP の亜種
eucJP-ascii 相当と思われる
x-IBM33722 Cp33722 IBM-eucJP - 日本語(5050のスーパー・セット) EUC-JP の亜種
x-PCK PCK Solaris版のShift_JIS Shift_JIS の亜種、
JIS 互換の Windows-31J
x-IBM942 Cp942 IBM OS/2 日本語、Cp932 のスーパー・セット Shift_JIS の亜種、
JIS C 6226-1978、IBM拡張文字
x-IBM942C Cp942C Cp942 の拡張機能 Shift_JIS の亜種、
5C7E の対応を ASCII に変更
x-IBM943 Cp943 IBM OS/2 日本語、Cp932 および Shift-JIS のスーパー・セット Shift_JIS の亜種、
JIS X 0208-1990、NEC特殊文字、IBM拡張文字
x-IBM943C Cp943C Cp943の拡張機能 Shift_JIS の亜種、
5C7E の対応を ASCII に変更
IBM1047 Cp1047 ラテン文字-1 (EBCDICホスト用) EBCDIC 英小文字拡張
IBM290 Cp290 IBM日本語カタカナ・ホスト拡張SBCS EBCDIC カナ文字拡張
x-IBM300 Cp300 IBM日本語ラテン・ホスト(ダブルバイト) EBCDIC IBM漢字
x-IBM930 Cp930 UDC 4370 文字を含む日本語カタカナ漢字、5026 のスーパー・セット EBCDIC カナ文字拡張、IBM漢字
x-IBM939 Cp939 UDC 4370 文字を含む日本語ラテン文字漢字、5035 のスーパー・セット EBCDIC 英小文字拡張、IBM漢字

A2. プログラミング言語での扱い (Ruby)

Java の String の実体は UTF-16 形式の Unicode でしたが、Ruby (1.9 以降) の String は文字コードが固定されておらず、String オブジェクトは自身の文字コード (エンコーディング) を保持しています。この方式を CSI (Code Set Independent) と呼びます。

A2-1. 文字列の走査、エンコード

ソースコードの1行目に # coding: utf-8 のように記述 (Magic Comment) すると、文字列のデフォルトのエンコーディング (Script Encoding) が指定できます。Magic Comment がない場合、Ruby 2.0 以降では UTF-8 が Script Encoding になります。

Java の例で示した、文字列の走査とエンコードの例を Ruby でも書いてみました。Ruby ではバイト単位、コードポイント単位で走査するメソッドが用意されており、正規表現で「書記素クラスタ」も使用できます。
文字列のエンコーディングが固定されていないので、エンコード/デコードの区別はなく、文字コードの変換は String#encode() で行います。

# coding: utf-8

def char_name(s)
  if s == "\r\n"
    "CR+LF"
  else
    s
  end
end

def bytes_to_hex_string(a)
  a.bytes.map {|x| sprintf('%02X', x)}.join(' ')
end

def chars_to_hex_string(s)
  s.chars.map {|c| sprintf('%04X', c.ord)}.join(' ')
end

#
# 正規表現を用いた文字列の走査。
#
def test_regex
  puts '--------'
  puts '# test_regex'

  text = "Aa1アガA1アガ亜\u00A5¥ ̄Å①彅\r\n" +
      "\u{20B9F}\u{23594}神神\uFE00カ\u309A\u02E9\u02E5"

  # 正規表現にマッチした単位で処理を行う。
  text.scan(/\u02E9\u02E5|\u02E5\u02E9|\X/).each {|s|
    if s[0].ord < 0x20
      printf("%s (%s)\n", chars_to_hex_string(s), char_name(s))
    else
      printf("%s [%s]\n", chars_to_hex_string(s), s)
    end
  }
end

#
# 各文字コードへの変換。
#
def test_encoding
  puts '--------'
  puts '# test_encoding'

  # UTF-8 文字列。
  text = "Aa1アガA1アガ亜\u00A5¥ ̄Å①彅\r\n" +
      "\u{20B9F}\u{23594}神神\uFE00カ\u309A\u02E9\u02E5"

  puts 'UTF-8:'
  puts bytes_to_hex_string(text)
  puts text

  # UTF-8 から Windows-31J に変換。
  w31j = text.encode('Windows-31J', :undef => :replace)
  puts 'Windows-31J:'
  puts bytes_to_hex_string(w31j)
  puts w31j.encode('UTF-8')
end

test_regex
test_encoding

実行結果:

--------
# test_regex
0041 [A]
0061 [a]
0031 [1]
FF71 [ア]
FF76 FF9E [ガ] ← 半角片仮名の濁音
FF21 [A]
FF11 [1]
30A2 [ア]
30AC [ガ]
4E9C [亜]
00A5 [¥]
FFE5 [¥]
FFE3 [ ̄]
212B [Å]
2460 [①]
5F45 [彅]
000D 000A (CR+LF) ← 改行コード
20B9F [𠮟] ← 追加面 (サロゲートペア)
23594 [𣖔]
FA19 [神]
795E FE00 [神︀] ← 異体字シーケンス
30AB 309A [カ゚] ← 結合文字列
02E9 02E5 [˩˥] ← これも結合文字列
--------
# test_encoding
UTF-8:
41 61 31 EF BD B1 EF BD B6 EF BE 9E EF BC A1 EF BC 91 E3 82 A2 E3 82 AC E4 BA 9C C2 A5 EF BF A5 EF BF A3 E2 84 AB E2 91 A0 E5 BD 85 0D 0A F0 A0 AE 9F F0 A3 96 94 EF A8 99 E7 A5 9E EF B8 80 E3 82 AB E3 82 9A CB A9 CB A5
Aa1アガA1アガ亜¥¥ ̄Å①彅
𠮟𣖔神神︀カ゚˩˥
Windows-31J:
41 61 31 B1 B6 DE 82 60 82 50 83 41 83 4B 88 9F 3F 81 8F 81 50 81 F0 87 40 FA 67 0D 0A 3F 3F FB 7E 90 5F 3F 83 4A 3F 3F 3F
Aa1アガA1アガ亜?¥ ̄Å①彅
??神神?カ???

A2-2. サポートされているエンコーディング (日本語関連)

よく利用する UTF-8、ISO-2022-JP、EUC-JP、Shift_JIS、Windows-31J などが定義されています。それぞれの亜種がいくつか定義されていますが、詳細は未確認です。

エンコーディング名 別名 (抜粋) 説明 補足
ASCII-8BIT BINARY ASCII 互換オクテット列用のエンコーディング。単なるバイトの列を表現するために用いる。
US-ASCII ASCII いわゆる ASCII のことで、ISO 646 IRV と一致。
UTF-8 CP65001 Unicode や ISO 10646 を ASCII 互換な形で符号化するための方式。
CESU-8
UTF-16 UTF-16 (BOMを含む)。
UTF-16BE UTF-16BE (ビッグエンディアン)。
UTF-16LE UTF-16LE (リトルエンディアン)。
UTF-32 UTF-32 (BOMを含む)。
UTF-32BE UTF-32BE (ビッグエンディアン)。
UTF-32LE UTF-32LE (リトルエンディアン)。
ISO-8859-1 ISO8859-1 多くの西欧言語を含むさまざまなラテン文字言語を表現するための 8bitエンコーディング。
ISO-2022-JP ISO2022-JP ISO 2022-JP エンコーディング。
ISO-2022-JP-2 ISO2022-JP2 ISO-2022-JP の拡張版。
EUC-JP eucJP 日本語 EUC 亜種。G0 が US-ASCII、G1 が JIS X 0201 片仮名図形文字集合、G2 が JIS X 0208、G3 が JIS X 0212。
EUC-JIS-2004 EUC-JISX0213
Shift_JIS 基本的には JIS X 0208:1997 の付属書1にある「シフト符号化表現」のことだが、7bit 部分が US-ASCII になっている。
Windows-31J CP932,
csWindows31J,
SJIS,
PCK
Windows で用いられる、シフトJIS 亜種。7bit 部分が論理的には US-ASCII であり、また Windows の機種依存文字を扱うことができる。 SJIS は Shift_JIS ではなく Windows-31J なので注意。
CP50220 Windows で用いられる、ISO-2022-JP 亜種。
CP50221 Windows で用いられる、ISO-2022-JP 亜種。ESC ( I でいわゆる半角カナを許し、Windows の機種依存文字を扱うことができる。
ISO-2022-JP-KDDI ISO-2022-JP の亜種。KDDI の携帯電話で使われる絵文字が含まれている。
stateless-ISO-2022-JP ISO-2022-JP をステートレスに扱うための方式。Emacs-Mule エンコーディングを元にしている。
stateless-ISO-2022-JP-KDDI stateless-ISO-2022-JP の亜種。KDDI の携帯電話で使われる絵文字が含まれている。
CP51932 Windows で用いられる、日本語 EUC 亜種。G0 が US-ASCII、G1 が JIS X 0201 片仮名図形文字集合、G2 が JIS X 0208 + Windows の機種依存文字、G3 は未割り当て。
eucJP-ms Unix 系で用いられる、日本語 EUC 亜種。
MacJapanese Mac OS の 9.x までで用いられていた Shift_JIS 亜種。
SJIS-DoCoMo Shift_JIS, CP932 の亜種。DoCoMo の携帯電話で使われる絵文字が含まれている。
SJIS-KDDI Shift_JIS, CP932 の亜種。KDDI の携帯電話で使われる絵文字が含まれている。
SJIS-SoftBank Shift_JIS, CP932 の亜種。SoftBank の携帯電話で使われる絵文字が含まれている。
UTF8-MAC アップルによって修正された Normalization Form D (分解済み) 形式の UTF-8。
UTF8-DoCoMo UTF-8 の亜種。DoCoMo の携帯電話で使われる絵文字が含まれている。
UTF8-KDDI UTF-8 の亜種。KDDI の携帯電話で使われる絵文字が含まれている。
UTF8-SoftBank UTF-8 の亜種。SoftBank の携帯電話で使われる絵文字が含まれている。

A3. プログラミング言語での扱い (Python)

Python (3.0 以降) の文字列 (str 型) は Unicode です。内部表現は環境やバージョンにより異なるようなので、抽象的なオブジェクトと理解しておくのがよいと思います。

A3-1. 文字列の走査、エンコード

ソースコードの1行目に # coding: utf-8 のように記述 (Magic Comment) すると、ソースコードのエンコーディング (Script Encoding) が指定できます。Magic Comment がない場合、UTF-8 が Script Encoding になります。

Java や Ruby の例で示した、文字列の走査とエンコードの例を Python で書いてみました。Pyton ではコードポイント単位で操作するメソッドが用意されています。正規表現は利用可能ですが、標準ライブラリでは「書記素クラスタ」はサポートしていないようです。
また、バイト単位に処理する場合は、str#encode() メソッドでバイト列に変換する必要があります。バイト列を文字列に戻すには bytes#decode() を使用します。エンコーディングを省略した場合は、どちらも UTF-8 になります。

import re

def char_name(s):
    if s == '\r\n':
        return 'CR+LF'
    elif s == '\r':
        return 'CR'
    elif s == '\n':
        return 'LF'
    else:
        return s

def bytes_to_hex_string(a):
    return ' '.join(['%02X' % x for x in a])

def chars_to_hex_string(s):
    return ' '.join(['%04X' % ord(c) for c in s])

# 論理的な文字を表す正規表現。
CHAR_REGEX = re.compile(r'\u02E9\u02E5|\u02E5\u02E9|\r\n|' \
        # 結合文字、半角濁点・半濁点。
        r'.[\u0300-\u036F\3099\309A\uFF9E\uFF9F' \
        # 異体字セレクタ。
        r'\uFE00-\uFE0F\U000E0100-\U000E01EF]*')

def test_regex():
    """正規表現を用いた文字列の走査。
    """
    print('--------')
    print('# test_regex')

    text = 'Aa1アガA1アガ亜\u00A5¥ ̄Å①彅\r\n' \
            '\U00020B9F\U00023594神神\uFE00カ\u309A\u02E9\u02E5'

    # 正規表現にマッチした単位で処理を行う。
    for s in CHAR_REGEX.findall(text):
        if ord(s[0]) < 0x20:
            print('%s (%s)' % (chars_to_hex_string(s), char_name(s)))
        else:
            print('%s [%s]' % (chars_to_hex_string(s), s))

def test_encoding():
    """Unicode から各文字コードへの変換。
    """
    print('--------')
    print('# test_encoding')

    # UTF-8 文字列。
    text = 'Aa1アガA1アガ亜\u00A5¥ ̄Å①彅\r\n' \
            '\U00020B9F\U00023594神神\uFE00カ\u309A\u02E9\u02E5'

    print('Unicode:')
    print(chars_to_hex_string(text))
    print(text)

    # Unicode から UTF-8 に変換。
    utf8 = text.encode()
    print('UTF-8:')
    print(bytes_to_hex_string(utf8))
    print(utf8.decode())

    # Unicode から Windows-31J に変換。
    w31j = text.encode('CP932', 'replace')
    print('Windows-31J:')
    print(bytes_to_hex_string(w31j))
    print(w31j.decode('CP932'))

test_regex()
test_encoding()

実行結果:

--------
# test_regex
0041 [A]
0061 [a]
0031 [1]
FF71 [ア]
FF76 FF9E [ガ]
FF21 [A]
FF11 [1]
30A2 [ア]
30AC [ガ]
4E9C [亜]
00A5 [¥]
FFE5 [¥]
FFE3 [ ̄]
212B [Å]
2460 [①]
5F45 [彅]
000D 000A (CR+LF)
20B9F [𠮟]
23594 [𣖔]
FA19 [神]
795E FE00 [神︀]
30AB [カ]
309A [゚]
02E9 02E5 [˩˥]
--------
# test_encoding
Unicode:
0041 0061 0031 FF71 FF76 FF9E FF21 FF11 30A2 30AC 4E9C 00A5 FFE5 FFE3 212B 2460 5F45 000D 000A 20B9F 23594 FA19 795E FE00 30AB 309A 02E9 02E5
Aa1アガA1アガ亜¥¥ ̄Å①彅
𠮟𣖔神神︀カ゚˩˥
UTF-8:
41 61 31 EF BD B1 EF BD B6 EF BE 9E EF BC A1 EF BC 91 E3 82 A2 E3 82 AC E4 BA 9C C2 A5 EF BF A5 EF BF A3 E2 84 AB E2 91 A0 E5 BD 85 0D 0A F0 A0 AE 9F F0 A3 96 94 EF A8 99 E7 A5 9E EF B8 80 E3 82 AB E3 82 9A CB A9 CB A5
Aa1アガA1アガ亜¥¥ ̄Å①彅
𠮟𣖔神神︀カ゚˩˥
Windows-31J:
41 61 31 B1 B6 DE 82 60 82 50 83 41 83 4B 88 9F 3F 81 8F 81 50 81 F0 87 40 ED 4B 0D 0A 3F 3F EE 62 90 5F 3F 83 4A 3F 3F 3F
Aa1アガA1アガ亜?¥ ̄Å①彅
??神神?カ???

A3-2. サポートされているエンコーディング (日本語関連)

よく利用する UTF-8、ISO-2022-JP、EUC-JP、Shift_JIS などが定義されています。Windows-31J は CP932 と指定します。

エンコーディング名 別名 (抜粋) 説明 補足
utf-8 utf8, cp65001
utf-8-sig BOM 印付き UTF-8
utf-16 utf16
utf-16-be UTF-16BE
utf-16-le UTF-16LE
utf-32 utf32
utf-32-be UTF-32BE
utf-32-le UTF-32LE
ascii us-ascii
latin-1 latin1, iso8859-1, iso-8859-1
iso2022-jp iso2022jp, iso-2022-jp
iso2022-jp-1 iso2022jp-1, iso-2022-jp-1
iso2022-jp-2 iso2022jp-2, iso-2022-jp-2
iso2022-jp-3 iso2022jp-3, iso-2022-jp-3 JIS X 0213:2000
iso2022-jp-2004 iso2022jp-2004, iso-2022-jp-2004 JIS X 0213:2004
iso2022-jp-ext iso2022jp-ext, iso-2022-jp-ext
euc-jp eucjp, ujis
euc-jisx0213 eucjisx0213 JIS X 0213:2000
euc-jis-2004 eucjis2004 JIS X 0213:2004
shift_jis sjis
shift_jisx0213 sjisx0213 JIS X 0213:2000
shift_jis-2004 shiftjis2004 JIS X 0213:2004
cp932 ms932, mskanji Windows-31J は指定不可

A4. 参考文献・URL