Skip to content

MS_CharacterValidation

nishi_74322014 edited this page Aug 21, 2026 · 1 revision

文字のチェック方式

概要

文字チェックは、文字化け等を事前に防ぐために行われる。

以下の文字チェック・ルーチンが一般的である。

補足(3 つの方式の位置付け): 本ページは
範囲チェック / 個別チェック / 可逆チェックの 3 つを扱う。
それぞれ得意な場面が違うので、先に整理しておく。

方式 仕組み 向く場面 弱点
範囲チェック コード値の範囲で判定 連続した領域(JIS 第1第2水準、外字領域) 飛び地には使えない
個別チェック 文字の一覧と突き合わせ 飛び地の文字(JIS2004 追加文字) 一覧の保守が要る
可逆チェック 往復変換して一致を見る 「この文字コードで送れるか」 実装依存。遅い

最も実務的なのは可逆チェックである。
「相手システムに送れるか」を直接確かめるため、
文字表の知識が要らない。本ページの最後で扱われている。

範囲チェック

エンコード後に、範囲チェックをする。

JIS第1第2水準漢字チェック

仕様:

JIS 第1第2水準漢字をチェックする

実装:

シフト JIS のコード範囲でチェックを行う必要があるので、
一文字づつシフト JIS にエンコード、数値型に変換し範囲チェックを行う。

サンプルコード:

//**********************************************************************************
//* All Rights Reserved, Copyright (C) 2007,2012 Hitachi Solutions,Ltd.
//**********************************************************************************

//**********************************************************************************
//* クラス名        :JISX0208_1983Checker
//* クラス日本語名  :JIS X 0208-1983文字コード範囲チェック・クラス
//*          ・01~08区:記号、英数字、かな
//*          ・16~47区:JIS第1水準漢字
//*          ・48~84区:JIS第2水準漢字
//*           ※JIS X 0208-1990で追加された「凜[7425]」「熙[7426]」は含まれない
//*          ※NEC機種依存文字、NECのIBM拡張文字、IBM拡張文字は含まれない

補足(区点と Shift_JIS の対応): コメントの「区」は
区点コードであり、そのままでは Shift_JIS のバイト値ではない。
原文が「シフト JIS にエンコード、数値型に変換し」と書いているのは、
区点ではなく Shift_JIS の 2 バイト値で判定するという意味である。

【Shift_JIS の 2 バイト文字の構造】
   1 バイト目: 0x81~0x9F、0xE0~0xEF(JIS X 0208 の範囲)
                0xF0~0xFC(外字領域)
                0xFA~0xFC(IBM 拡張文字)
   2 バイト目: 0x40~0x7E、0x80~0xFC

1 バイト目と 2 バイト目の範囲が重なっているため、
バイト単位で走査すると誤判定する(いわゆる「5C 問題」)。

「表」= 0x95 0x5C
           ↑ 2 バイト目が 0x5C(= ASCII の "\")

→ バイト列を素朴に扱うと、パス区切りやエスケープと誤認される
→ 「ソ」(0x83 0x5C)、「十」(0x8F 0x5C) 等も同様

原文が「一文字づつ」と強調しているのは、この問題を避けるためであり、
重要な指摘である。
現在の .NET では string が UTF-16 なので、
foreach (char c in s) で自然に文字単位になる
が、
byte[] に落としてから走査すると同じ罠にはまる。

なお、この判定は現在では「文字集合の絞り込み」として使われる
目的は文字化け防止ではなく、

・レガシー システム(CP932 前提)へ連携する際の入力制限
・帳票の外字フォントに存在する文字だけに限定する
・氏名欄で「登録可能な文字」を業務ルールとして定める

といった業務要件の実装である。
純粋な文字化け対策としては、UTF-8 で通す方が根本的である
アプリケーションのUnicode化)。

Unicodeの外字範囲チェック

仕様:

Unicode の外字範囲をチェックする。

  • BMP 領域 U+E000‐U+F8FF (6,400字)
  • 15面 U+F0000‐U+FFFFD (65,534字)
  • 16面 U+100000‐U+10FFFD (65,534字)
  • 余談:シフト JIS の外字範囲:U+E000~U+E757(0xF040~0xF9FC)。

実装:

  • 一文字づつ Unicode 数値に変換し範囲チェックを行う。
  • Java、.NET の文字列は Unicode のため Unicode の範囲チェックは、
    以下の様なコードで簡単にチェックできる。

サンプルコード:

(Unicode? JIS X 0221? 各国の工業規格で使用されるか?)

string from = "文字列を初期化する。"
StringBuilder to = new StringBuilder();

foreach (char c in from)
{
    int charCode = (int)c;

    if (57344 <= charCode && charCode <= 63743)
    {
        // Unicodeの外字範囲
         // BMP領域 U+E000‐U+F8FF (6,400字)
    }
    else if (983040 <= charCode && charCode <= 1048573)
    {
        // Unicodeの外字範囲
         // 15面 U+F0000‐U+FFFFD (65,534字)
    }
    else if (1048576 <= charCode && charCode <= 1114109)
    {
        // Unicodeの外字範囲
         // 16面 U+100000‐U+10FFFD (65,534字)
    }
    else
    {
        // Unicodeの外字でない。
    }
}

移行メモ(このサンプルは 15 面・16 面を検出できない): 仕様は
正しいが、実装がその仕様を満たしていない

【問題】
   C# の char は【16 ビット(UTF-16 の 1 単位)】である
     → (int)c の最大値は 65535
     → 983040(U+F0000)以上の条件は【絶対に成立しない】

   15 面・16 面の文字は【サロゲート ペア】で表現されるため、
   char 1 つずつ見ていては検出できない
     → U+F0000 は char 2 つ(0xDB80, 0xDC00)に分かれている

本ページの後半でサロゲート ペアの話が別項目として出てくるが、
この 2 つは同じ問題であり、
サロゲート ペアを考慮した走査が必要である。

修正版(char ではなくコードポイントで走査する):

// .NET Core 3.0 以降:Rune がコードポイントを表す
foreach (Rune r in from.EnumerateRunes())
{
    int cp = r.Value;   // ← 0x10FFFF まで正しく入る

    if (0xE000 <= cp && cp <= 0xF8FF)          // BMP の私用領域
    { /* 外字 */ }
    else if (0xF0000 <= cp && cp <= 0xFFFFD)   // 15 面(補助私用領域 A)
    { /* 外字 */ }
    else if (0x100000 <= cp && cp <= 0x10FFFD) // 16 面(補助私用領域 B)
    { /* 外字 */ }
    else
    { /* 外字でない */ }
}

Rune が使えない環境(.NET Framework)では、
char.ConvertToUtf32 を使う。

for (int i = 0; i < from.Length; i++)
{
    int cp;
    if (char.IsHighSurrogate(from[i]) && i + 1 < from.Length
        && char.IsLowSurrogate(from[i + 1]))
    {
        cp = char.ConvertToUtf32(from[i], from[i + 1]);
        i++;   // ← ペアなので 2 つ進める
    }
    else
    {
        cp = from[i];
    }
    // cp で判定する
}

Rune を使えば、この定型処理を書かずに済む
.NET Core 3.0 以降なら Rune が第一選択である。

補足(私用領域の性質): 仕様に挙げられた 3 つの領域は、
Unicode では **PUA(Private Use Area、私用領域)**と呼ばれる。

領域 名称 字数
U+E000~U+F8FF 私用領域(BMP 内) 6,400
U+F0000~U+FFFFD 補助私用領域 A(15 面) 65,534
U+100000~U+10FFFD 補助私用領域 B(16 面) 65,534

**「私用」の意味は「Unicode は何も定義しない」**である。

・どの文字を割り当てるかは【使う側の勝手】
・したがって【組織をまたぐと意味が変わる】
   → 外字が化ける根本原因([Windowsの外字] 参照)
・フォントがなければ □ や豆腐で表示される

原文が「シフト JIS の外字範囲:U+E000~U+E757」と書いているのは、
CP932 の外字領域(0xF040~0xF9FC、1,880 字)が
Unicode の私用領域の先頭に順に対応付けられている
ためである。
これは Microsoft がそう決めただけの対応であり、
他社の変換表では別の値になり得る

現在の指針:

・新規システムで【私用領域を使わない】
・既存の外字は【IVS(異体字セレクタ)や Unicode の正規の文字へ移行】
   → [Windowsの外字] を参照
・どうしても残すなら、【変換表を明示的に管理する】

個別チェック

コード表で連続しない文字をチェックする。

JIS2004チェック

追加文字チェック

  • 仕様:
    JIS2004 で追加された文字をチェックする。
  • 実装:
    Unicode のみに存在し、コード範囲もバラバラなので、
    JIS2004 追加文字配列を初期化し、一文字づつ、比較処理を行う。

サロゲートペア文字チェック

  • 仕様:
    JIS2004 で追加されたサロゲートペア文字をチェックする。

  • 実装:

    • Regex.IsMatch() メソッド、char.IsSurrogate() メソッドを使用する。

    • Regex.IsMatch() メソッド(正規表現)を使用する。

      • 以下サンプル・コード。

        //ここに判定する文字列を入れる。
        string strSurrogatesPair = textBox1.Text;
        Regex rg = new Regex("^[^\uD800-\uDBFF\uDC00-\uDFFF]+$");
        //サロゲート ペア文字が文字列中に含まれているか
        //Regex.IsMatch() メソッド判定
        if ( rg.IsMatch( strSurrogatesPair ) )
        {
          // サロゲートペア文字が含まれていない。
        }
        else
        {
          // サロゲートペア文字が含まれている。
        }
        
        // 結合文字はチェックできない。
    • char.IsSurrogate() メソッドを使用する。

      • 以下サンプル・コード。

        //ここに判定する文字列を入れる。
        string strSurrogatesPair = textBox1.Text;
        
        //サロゲート ペア文字が文字列中に含まれているか
        //char.IsSurrogate()メソッドで判定
        int i = 1;
        foreach (char ch in strSurrogatesPair.ToCharArray())
        {
          if(char.IsSurrogate(ch))
          {
            MessageBox.Show(i.ToString() + "文字目にサロゲート ペア文字が含まれています");
            return;
          }
          else
          {
            i++;
          }
        }
        
        MessageBox.Show("サロゲート ペア文字が含まれていません");
      • サロゲート ペア文字を削除するサンプル コード

        //ここに判定する文字列を入れる。
        string strSurrogatesPair = textBox1.Text;
        
        StringBuilder sb = new StringBuilder();
        
        //サロゲート ペアが文字列中に含まれているか
        //char.IsSurrogate()メソッドで判定
        foreach (char ch in strSurrogatesPair.ToCharArray())
        {
          if (char.IsSurrogate(ch))
          {
            // 破棄
          }
          else
          {
            sb.Append(ch);
          }
        }
        
        textBox1.Text = sb.ToString();

※ 前者は、存在チェックのみ、
  後者は、文字の位置まで特定可能(∴削除も可能。)。

※ また、上記の方法では、結合文字はチェックできない。

補足(サンプルの注意点): どちらも動くが、
細部に注意すべき点がある

① 正規表現版は「空文字列」で結果が反転する

// "^[^...]+$" は 1 文字以上を要求する
rg.IsMatch("");   // false → 「含まれている」と誤判定される

+* にするか、空文字列を先に弾く

char.IsSurrogate 版は「文字目」の数え方が実際とずれる

"あ𠮷い" を走査すると
   index 0: 'あ'          → i = 2
   index 1: 0xD842(上位)→ ここで「2 文字目」と報告
   実際の見た目は【2 文字目】で合っているが、
   サロゲートより前にサロゲートがあると、以降が 2 ずつずれる

見た目の位置を出したいなら StringInfo を使う(後述)。

③ 削除サンプルは「壊れた文字列」を作り得る

char.IsSurrogate は上位・下位の両方に true を返すため、
ペアの両方が消える。この点は正しい。
ただし孤立サロゲート(ペアの片割れだけ)が入力にあった場合も
黙って消えるため、不正な入力を検出できない

現在の書き方:

// サロゲート ペア(BMP 外の文字)を含むか
bool hasNonBmp = str.EnumerateRunes().Any(r => !r.IsBmp);

// BMP 外の文字を除去する
string removed = string.Concat(
    str.EnumerateRunes().Where(r => r.IsBmp).Select(r => r.ToString()));

Rune は不正なサロゲートを U+FFFD(置換文字)として返すため、
壊れた入力が検出できる利点がある。

結合文字チェック

  • 現状、ハッキリした方法が無い。
    • サロゲートペアや結合文字が含まれているか調べる .NET Tips C#, VB.NET
      https://dobon.net/vb/dotnet/string/issurrogatepair.html#section4\ 結合文字が含まれているか調べる

      ただし、Marks カテゴリにすべての結合文字が含まれているか、
      そして、結合文字以外の文字が一切含まれていないかについては、はっきりしていません。

補足(現在は StringInfo / TextElementEnumerator で扱える): 原文の
「ハッキリした方法が無い」という状況は、現在は改善している

.NET 5 以降、StringInfo は Unicode の
書記素クラスタ(Grapheme Cluster)の規則(UAX #29)に準拠
した。
つまり、「人間が 1 文字と見なす単位」を正しく数えられる

// 「見た目の 1 文字」で列挙する
var e = StringInfo.GetTextElementEnumerator("が𠮷👨‍👩‍👧");
while (e.MoveNext())
    Console.WriteLine(e.GetTextElement());
// が   ← 結合文字(か + 濁点)が 1 つにまとまる
// 𠮷    ← サロゲート ペアが 1 つ
// 👨‍👩‍👧  ← ZWJ で繋がった絵文字も 1 つ
// 結合文字(Mark カテゴリ)を含むか
bool hasCombining = s.Any(c =>
{
    var cat = CharUnicodeInfo.GetUnicodeCategory(c);
    return cat == UnicodeCategory.NonSpacingMark
        || cat == UnicodeCategory.SpacingCombiningMark
        || cat == UnicodeCategory.EnclosingMark;
});

**より実務的なのは「正規化してから比較する」**方法である。

// 合成済み文字に寄せる(NFC)
string nfc = s.Normalize(NormalizationForm.FormC);

// 「が」は 2 通りの表現がある
"が" (U+304C)             ← 合成済み(NFC)
"か" + "゙" (U+304B U+3099) ← 分解(NFD)

// 正規化すれば、どちらも同じになる
Assert.Equal("が", "が".Normalize(NormalizationForm.FormC));
形式 内容 用途
NFC 合成済みに寄せる DB 保存・比較の既定。Windows の慣習
NFD 分解する macOS のファイル名がこれ
NFKC 合成+互換分解(全角→半角、① → 1) 検索キーの正規化
NFKD 分解+互換分解 同上

NFKC は影響が大きい点に注意する。
(株)1A のように変わるため、
氏名や住所をそのまま NFKC すると、原文が失われる
検索用のキーを別に持つのが正しい設計である。

【macOS 由来のファイル名が「濁点が分かれている」問題】
   macOS(HFS+)は【NFD 相当】でファイル名を保存する
     → 「ガ」が「カ + ゙」の 2 文字として届く
     → Windows 側で検索が一致しない
   対策: 受信時に NFC 正規化する

Lengthチェック

サロゲート ペア文字・結合文字は、

  • 通常の Length チェックでは、2 文字分として表示される。ただし、見た目の文字は 1 文字。
  • また、プログラム中でのバイト表現(UTF-16)でのバイト長は、通常の文字が 2 バイトであるのに対し、4 バイト。

サロゲート ペア文字・結合文字を含む文字列の見た目の文字列長を調べる場合は、

  • System.Globalization 名前空間の StringInfo クラスを使用する。
  • string.Length、string.Substring などは原則禁止
    (string.Length は、文字列のバイト長を調査する場合などは使用可能)
  • ただし、.NET Framework 1.1 以前のランタイムでは、
    このクラスの仕様が異なり下記の様に利用できない。
/// <summary>文字列情報の表示</summary>
/// <param name="strSurrogatesPair">文字列</param>
private void GetStringInfo(string strSurrogatesPair)
{
  // System.Globalization.StringInfoを使用する。
  StringInfo si = new StringInfo(strSurrogatesPair);

  // 文字列を表示
  MessageBox.Show(strSurrogatesPair);
  // 長さを表示1
  MessageBox.Show("長さ(文字列長1):" + strSurrogatesPair.Length);
  // 長さを表示2
  MessageBox.Show("長さ(文字列長2):" + si.LengthInTextElements);
  // 長さを表示3
  MessageBox.Show("長さ(プログラム中(UTF-16)でのバイト長):" +
  Encoding.Unicode.GetBytes(strSurrogatesPair).Length);
}

補足(「Length は原則禁止」の意味): 原文のこの指針は
強い表現だが、方向は正しい。ただし
「何を数えたいか」で答えが変わるため、整理しておく。

string s = "あ𠮷が";   // あ + 𠮷(サロゲート)+ か + 濁点

s.Length                                    // 5(UTF-16 の単位数)
s.EnumerateRunes().Count()                  // 4(コードポイント数)
new StringInfo(s).LengthInTextElements      // 3(見た目の文字数)★
Encoding.UTF8.GetByteCount(s)               // 13(UTF-8 のバイト数)
Encoding.Unicode.GetByteCount(s)            // 10(UTF-16 のバイト数)
目的 使うもの
入力欄の「○文字まで」の判定 LengthInTextElements(利用者の感覚に一致)
DB の nvarchar(n) に収まるか s.Length(SQL Server の n は UTF-16 単位)
ファイル・通信のサイズ見積り Encoding.UTF8.GetByteCount
固定長ファイル(CP932)の桁数 Encoding.GetEncoding(932).GetByteCount
単に空かどうか string.IsNullOrEmptyLength == 0 より明確)

Substring が危険な理由も同じである。

// ✗ サロゲート ペアの途中で切ると、壊れた文字ができる
"𠮷野家".Substring(0, 1);   // 孤立サロゲート(表示は "?")

// ○ 見た目の文字で切る
var si = new StringInfo("𠮷野家");
si.SubstringByTextElements(0, 1);   // "𠮷"

「…」で省略表示する処理は、この罠の典型である。
絵文字や結合文字を含む入力(現在はごく普通に来る)で、
文字が壊れる/豆腐になるという不具合になる。

移行メモ: 原文の「.NET Framework 1.1 以前のランタイムでは
このクラスの仕様が異なり」という注記は、
現在は考慮不要である(.NET Framework 1.1 は 2013 年にサポート終了)。
むしろ、.NET 5 で StringInfo が UAX #29 準拠に修正されたことの方が
現在の関心事である(それ以前は絵文字の扱いが不正確だった)。

サンプルコード:

可逆チェック

エンコーディングを使用して可逆チェックをする。

特定のエンコーディングの

  • コードページ範囲内の文字であるか?
  • Unicode からの双方向のエンコーディングか可能か?

をチェックできる。

例:MS932の範囲内であるか?のチェック

  • 以下のようにエンコーディングを行う。

    1. ①Unicode 文字列
    2. → MS932 エンコーディング
    3. → ②MS932 文字列(バイト配列)
    4. → MS932 デコーディング
    5. → ③Unicode 文字列
  • 上記の結果から、
    ①Unicode 文字列と、③Unicode 文字列を比較し=か?をチェックする。

補足(実装と落とし穴): この方式が最も実務的である。
文字表を持たずに「相手に送れるか」を直接確かめられる。

// .NET Core 以降は最初に 1 度だけ登録が必要
Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);

static bool IsRoundTrippable(string s, int codePage)
{
    var enc = Encoding.GetEncoding(codePage);
    return enc.GetString(enc.GetBytes(s)) == s;
}

IsRoundTrippable("髙島屋", 932);   // true (CP932 にある)
IsRoundTrippable("𠮷野家", 932);   // false(JIS2004 の文字)
IsRoundTrippable("café", 932);     // false(é が CP932 にない)

落とし穴 ①:既定では失敗が「?」に化けて分からない

上の実装で false は返るが、
「どの文字が駄目だったか」は分からない
例外を出す設定にすると、位置が特定できる

var enc = Encoding.GetEncoding(932,
    EncoderFallback.ExceptionFallback,
    DecoderFallback.ExceptionFallback);

try { enc.GetBytes(s); }
catch (EncoderFallbackException ex)
{
    // ex.Index      … 何文字目か
    // ex.CharUnknown … 変換できなかった文字
}

落とし穴 ②:波ダッシュ問題で「往復するのに値が変わる」

"〜"(U+301C) → CP932 → 0x8160 → Unicode → "~"(U+FF5E)
   → 往復チェックは【false】になる
   → しかし、見た目は同じで、実務上は「送れている」

どちらを正とするかは業務判断である
エンコーディング の事例4)。
厳密に判定したいなら、事前に正規化してから往復させる

落とし穴 ③:重複符号化された文字

NEC 選定 IBM 拡張文字は、往復で別のバイト値になるが、
Unicode に戻せば同じ文字なので、
この方式では検出されない(=送れると判定される)。
バイト値の一致まで求めるなら、バイト列同士を比較する

落とし穴 ④:性能

1 文字ごとに GetEncoding を呼ぶ実装は遅い。
Encoding インスタンスは使い回す(スレッド セーフ)。

// ✗ ループの中で毎回取得
foreach (var s in list) { Encoding.GetEncoding(932)... }

// ○ 1 度だけ取得して使い回す
static readonly Encoding Cp932 = Encoding.GetEncoding(932);

補足(入力チェックの現在の設計): 3 方式を踏まえた上での、
現在の実務的な指針をまとめる。

① まず「なぜ制限するのか」を決める

理由 適切な手段
連携先が CP932 しか受けられない 可逆チェック(相手の文字コードで)
帳票のフォントに字がない フォント側の対応文字で個別チェック
業務ルール(氏名は漢字・かなのみ等) 正規表現による文字種チェック
文字化けが怖い(漠然と) 制限しない。UTF-8 で通す方が根本的

最後の行が重要で、
「念のため」で文字を制限すると、利用者の氏名が入力できない
という問題を生む。
制限には必ず理由が要る

② 制限するなら、入口で・明確なメッセージで

✗ 「入力に誤りがあります」
○ 「3 文字目の『𠮷』は登録できません。『吉』でご入力ください」

どの文字が駄目かを返せる実装にしておく
(前述の EncoderFallbackExceptionIndex / CharUnknown)。

③ 正規化のタイミングを決める

【入力時】 NFC 正規化 → 保存
           (macOS 由来の NFD を吸収する)
【検索時】 NFKC 正規化した別カラムで突き合わせる
           (全角・半角、丸数字の揺れを吸収する)

④ DB 側も合わせる

推奨
列の型 nvarcharvarchar はコードページ依存)
SQL Server 2019 以降 UTF-8 照合順序varchar も選べる
照合順序 _SC 付き(Supplementary Characters。サロゲート ペア対応

_SC が付いていない照合順序では、
LEN()SUBSTRING() がサロゲート ペアを正しく扱えない

.NET 側の StringInfo と同じ問題が DB 側にもある
SQL Server)。

参考

Microsoft Learn


Tags: 移行, .NET開発, 国際化対応, 文字コード

NetDevInfraWiki

マイクロソフト系技術情報 Wiki
Open 棟梁 Wiki

(未着手)

開発基盤部会 Wiki

移行管理: DONETODO

Clone this wiki locally