外字(私用領域)の扱い — SnowStack.EncodingProbe

2026/09/30 document update

SnowStack.EncodingProbe が、外字をどう扱うかの解説です。

外字とは、文字コードの規格に無い文字を、利用者や会社が独自に割り当てて使う文字のことです。社内システムの人名の異体字や、機種依存の記号などで使われてきました。

古い Shift_JIS のデータを UTF-8 へ移行するとき、必ず問題になるのがこの外字です。

最初に結論を書いておきます。

SnowStack.EncodingProbe は、外字に介入しません。 .NET の System.Text.Encoding の動作に従い、外字は Unicode の「私用領域」の文字として、そのまま利用者に渡します。

外字を本来の文字に置き換えることも、エラーや警告を出すこともしません。その代わり、読み込んで、加工せずに同じ文字エンコーディングで書き戻せば、外字のバイト列は元のまま保存されます。

この記事では、なぜその方針にしたのか、実際にどう動くのか、外字を本来の文字に置き換えたいときはどうすればよいのかを解説します。

この記事の内容は、NuGet パッケージ(クラスライブラリ)と PowerShell モジュールの両方に当てはまります。それぞれの解説は、以下の記事で行っています。

SnowStack.EncodingProbe NuGet Package 解説

SnowStack.EncodingProbe.PowerShell 解説

(この記事に掲載した実行結果は、すべて実機で採取したものです。採取日は 2026年9月28日、環境は Windows 11 上の PowerShell 7.6.6 と Windows PowerShell 5.1.26100 で、特に断らない限り両ホストで同じ結果になります。付録の往復の検証は、Ubuntu 24.04 と macOS 15.7 の PowerShell 7.6 でも同じ件数になることを確認しています)

外字とは — 規格が文字を決めていないバイト列

Shift_JIS などの旧マルチバイトの文字エンコーディングには、規格として文字を決めていない領域があります。このライブラリが扱う範囲では、2 種類あります。

種類 内容
ユーザー定義領域 利用者が独自の文字を割り当てるために空けてある 2 バイトの領域。日本語では「外字」、繁体字中国語では「造字」と呼ばれます。規格上は EUDC(End-User-Defined Characters)といいます
未定義の 1 バイト 規格として何も割り当てられていない 1 バイトの値

この記事では、両方をまとめて「外字」と呼びます。

たとえば Shift_JIS(Windows の CP932)では、F0 40 から始まる領域がユーザー定義領域です。

.NET は、外字を「私用領域」の文字として読みます

Unicode には、私用領域(Private Use Area、U+E000〜U+F8FF)という範囲があります。

Unicode が文字の意味を決めていない、利用者が自由に使ってよい領域です。

.NET の System.Text.Encoding は、外字のバイト列を、この私用領域の符号位置へ位置の順に機械的に割り当てて読み込みます。例外も発生せず、置換文字(? など)にもなりません。

文字エンコーディング バイト列 読み込んだ文字
CP932(Shift_JIS) F0 40 から U+E000 から
CP950(Big5) FA 40 〜 FA 5F U+E000 〜 U+E01F
CP950(Big5) 81 40 〜 88 62 U+EEB8 〜 U+F325

未定義の 1 バイトも、コードページごとに異なる私用領域の符号位置へ割り当てられています。

コードページ バイト 読み込んだ文字
932(Shift_JIS) A0, FD, FE, FF U+F8F0〜U+F8F3
936(GBK) FF U+F8F5
949(韓国語 CP949) FF U+F8F7
950(Big5) FF U+F8F8
20932 / 51932(EUC-JP) A0, FF U+F8F0, U+F8F3
51949(EUC-KR) A0, AD, AE, AF, FE, FF U+F8E6〜U+F8EB

割り当て先が、コードページごとに重ならないように分けてあります。この仕組みが、読み込んで書き戻したときに元のバイト列へ戻す(往復する)ことを目的にしていると分かります。

私用領域の文字は、「何の字か分からない」という意味です

ここが、外字を考えるうえで一番大事な点です。

私用領域の文字は、Unicode が意味を決めていません。「どの字なのか」を決めているのは、バイト列の外側にある取り決め(外字フォント、入力環境、地域や会社の慣行)です。その取り決めは、ファイルの中には書かれていません。

したがって、同じ U+F325 という符号位置が、香港では HKSCS(香港の拡張文字)のある字を、台湾では Big5-UAO(台湾の拡張文字)の別の字を、別の会社では独自の造字を意味することがあります。

私用領域の文字として読み込まれたということは、「その字が何かを特定できなかった」という意味です。

SnowStack.EncodingProbe の方針 — 介入しない

SnowStack.EncodingProbe は、文字エンコーディングの解釈、Unicode との対応づけ、変換できない文字の扱いについて、.NET の System.Text.Encoding が定める動作をそのまま使います。独自の解釈は加えません。

外字についても、読み込み・書き込み・判定のいずれでも、.NET の既定の動作をそのまま利用者に渡します。

具体的には、以下のことを行いません。

  • 私用領域の文字を、実在する Unicode の文字に置き換えない
  • HKSCS・Big5-UAO・倚天(ETen)拡張などの対応表を同梱しない
  • 外字を含むことを理由に、判定結果や文字エンコーディングの名前を変えない
  • 外字を含むことを理由に、警告やエラーを出さない

Web ブラウザが従う WHATWG Encoding Standard には、Big5 の外字の領域を HKSCS の文字へ置き換える規則があります。このライブラリは、その解釈を採用していません。理由は、上に書いたとおり「その外字が何の字か」はバイト列からは分からないからです。

判定への影響

文字エンコーディングの判定では、外字のバイト列を、その文字エンコーディングとして「規格内」のバイト列として扱います。

外字が含まれていることを、判定の材料にはしません。「外字があるから Shift_JIS ではない」とも、「この領域の外字があるから香港の Big5 だ」とも判断しません。外字は「字を特定できなかった」ことを意味するので、そこから地域や変種を推し量ることは、原理的にできないからです。

読み込みと書き込みでの動作

場面 動作
読み込み 私用領域の文字として、そのまま返す。警告やエラーは出さない
書き込み 私用領域の文字を、元のバイト列へそのまま戻す

実際に試す

Shift_JIS の外字を含むファイルを用意しました。外字領域の最初の 2 文字(F0 40 と F0 41)を、人名の一部に使っているという想定です。

# 外字を含む Shift_JIS のファイルのバイト列
Show-Bytes .\customers.txt
8E 52 93 63 F0 40 98 59 0D 0A 8D B2 93 A1 F0 41 0D 0A

8E 52 93 63 が「山田」、F0 40 が外字、98 59 が「郎」です。2 行目の 8D B2 93 A1 が「佐藤」、F0 41 が外字です。

(Show-Bytes は、ファイルの中身を 16 進数で表示する自作のヘルパー関数です。定義は 1.1.0 新コマンド解説の記事 に載せています)

判定すると、外字を含んでいても、普通に Shift_JIS と判定されます。

Resolve-Encoding .\customers.txt
CodePage        : 932
EncodingWebName : shift_jis
...

読み込んだ文字列の 1 行目を、1 文字ずつ符号位置で表示すると、以下のようになります。

U+5C71 U+7530 U+E000 U+90CE

「山」「田」の次の外字が、私用領域の U+E000 として読み込まれています。

このファイルを Get-ProbedContent -Raw で読み、加工せずに Set-ProbedContent -EncodingFrom で書き戻すと、外字の F0 40 / F0 41 を含めて、元のバイト列がそのまま保存されます。

# 読み込んで、加工せずに同じ性質で書き戻す
$text = Get-ProbedContent .\customers.txt -Raw
$text | Set-ProbedContent .\customers.txt -EncodingFrom .\customers.txt -NoNewline
Show-Bytes .\customers.txt
8E 52 93 63 F0 40 98 59 0D 0A 8D B2 93 A1 F0 41 0D 0A

書き戻す前とまったく同じバイト列です。両ホストで同じ結果になります。

コードページごとの可逆性

外字が私用領域の文字として読み込まれ、書き戻したときに元のバイト列に戻るかどうかは、コードページによって違います。

コードページ 文字エンコーディング ユーザー定義領域の扱い 可逆性
932 Shift_JIS(Windows-31J) 私用領域の文字として読む 可逆
20932 EUC-JP 私用領域の文字として読む 可逆
51932 EUC-JP(CP51932) ・(U+30FB)に置き換えて読む 非可逆・検出不能
936 GBK 私用領域の文字として読む 可逆
51936 EUC-CN 私用領域の文字として読む 可逆
54936 GB18030 私用領域の文字として読む 可逆
949 統合ハングル(CP949) 私用領域の文字として読む 可逆
51949 EUC-KR ? に置き換えて読む 非可逆・検出可能
950 Big5(CP950) 私用領域の文字として読む 可逆

「可逆」としたコードページでは、「バイト列 → 文字 → バイト列」と「文字 → バイト列 → 文字」の両方向で、往復に失敗する外字が 0 件であることを確認しています(検証方法は記事の最後の付録に載せています)。

コードページ 51950(EUC-TW)は、.NET が提供していないため、読み込みも書き込みもできません。

注意 — 日本語の EUC-JP には、コードページが二つあります

日本語の EUC-JP には、コードページ 20932 と 51932 の二つがあり、どちらも euc-jp という名前を持っています。

Encoding.GetEncoding("euc-jp") が返すのは、51932 の方です。

二つは、外字の扱いが違います。

バイト列 cp20932 cp51932
F5 A1(ユーザー定義領域) U+E000 → F5 A1(可逆) U+30FB(・)→ A1 A6(非可逆)
8F B0 A1(補助漢字の 3 バイト) U+008F U+4E9C → 8F B0 A1(可逆) U+30FB U+4E9C → A1 A6 B0 A1(非可逆)

cp51932 は、外字を実在する文字の ・ に置き換えて読み込みます。置き換えた結果が普通の文字なので、読み込んだ文字列を見ても、外字が失われたことに気付けません。

なお、どちらのコードページも JIS X 0212(補助漢字)を解釈しません。cp20932 は補助漢字を表す 8F を制御文字としてそのまま返すので、補助漢字としては読めませんが、バイト列は保存されます。

SnowStack.EncodingProbe は、20932 を返します(日本語の判定の場合)

この違いがあるので、SnowStack.EncodingProbe の独自の判定処理は、日本語の EUC-JP を検出したときに、コードページ 20932 を返します。外字を保存できる方です。

ただし、EncodingWebName は .NET の Encoding.WebName をそのまま返すので、euc-jp になります。この名前で Encoding.GetEncoding を呼ぶと、51932 が返ってきます。

クラスライブラリを使う方は、EncodingWebName ではなく CodePage でエンコーディングを作ってください。

// .NET 10 では、20932 などのコードページを使う前に登録が必要
Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);

var info = EncodingProbe.Detect(path);

// 外字を保存できる 20932 が得られる
var encoding = Encoding.GetEncoding(info.CodePage);

// こちらは 51932 になり、外字が「・」に置き換わる
var encoding2 = Encoding.GetEncoding(info.EncodingWebName);

PowerShell のコマンドレット(Get-ProbedContent など)は、内部で CodePage を使っているので、この問題は起きません。

UTF.Unknown が判定した場合は、51932 になります

もう一点、注意があります。

20932 を返すのは、独自の判定処理が EUC-JP と判定した場合だけです。独自の判定処理が日本語の EUC-JP を判定するのは、日本語のカルチャーの環境で、判定方式が既定の Combined か NativeOnly のときです。

それ以外の場合、つまり欧米などのカルチャーの環境や、-Strategy UtfUnknownOnly を指定した場合は、サードパーティ製の UTF.Unknown が判定し、その結果の 51932 になります。

外字を含む EUC-JP のファイルで試した結果です。

カルチャー 判定方式 判定結果 外字(F5 A1)の読み込み結果
ja-JP Combined(既定) 20932 U+E000(外字のまま)
ja-JP UtfUnknownOnly 51932 U+30FB(・)
de-DE Combined(既定) 51932 U+30FB(・)

外字を含む EUC-JP のファイルを扱う場合は、日本語以外の環境では -Culture ja-JP を指定するか、-Encoding 20932 で明示してください。

なお、1.2.0 で追加した Convert-ProbedContent は、変換元を読むときに置き換えを許さないので、51932 と判定された場合は変換を拒否します。

# 外字を含む EUC-JP を、ドイツ語のカルチャーで UTF-8 に変換しようとする
Convert-ProbedContent .\euc.txt -Culture de-DE -Encoding utf8NoBOM

エラー ID は InvalidSourceBytes で、ファイルは変更されません。-Culture ja-JP を付ければ、20932 として読み、外字を U+E000 のまま UTF-8 に変換します。

外字を、本来の文字に置き換える

外字の意味としての解釈が必要な場合は、読み込んだ文字列に対して、利用者が自分の対応表で置き換えてください。

このライブラリは外字を私用領域の文字としてそのまま返すので、その置き換えは利用者の側で確実に行えます。

先ほどの Shift_JIS のファイルで、外字領域に社内の造字を割り当てて運用してきたものを、UTF-8 へ移行する例です。

# 利用者が持っている外字の対応表(符号位置 → 本来の文字)
$gaiji = @{ 0xE000 = '𠮷'; 0xE001 = '髙' }

$text = Get-ProbedContent .\customers.txt -Raw

# 私用領域の文字が含まれるかを確認する
if ($text -match '[-]') { '外字が含まれています' }

# 対応表で置き換える
$fixed = -join ($text.ToCharArray() | ForEach-Object {
    $cp = [int]$_
    if ($gaiji.ContainsKey($cp)) { $gaiji[$cp] } else { $_ }
})

$fixed | Set-ProbedContent .\customers_utf8.txt -Encoding utf8NoBOM -NoNewline

できあがった customers_utf8.txt は、「山田𠮷郎」「佐藤髙」の 2 行になります。

この処理が成り立つのは、このライブラリが私用領域の文字をそのまま返すからです。もし ? に置き換えたり、例外を投げたりしていたら、利用者は対応表を引く手がかりを失います。

対応表は、環境ごとに違います。U+E000 が何の字なのかを知っているのは、利用者だけです。

このライブラリが対応表を同梱しないのは、機能が足りないからではなく、知り得ないからです。香港の HKSCS も同じで、利用者が対応表を持っていれば、上の $gaiji を差し替えるだけで同じ処理ができます。

(.ps1 ファイルに保存して実行する場合は、UTF-8(BOM 付き)で保存してください。Windows PowerShell 5.1 は BOM の無い .ps1 を ANSI として読むので、'𠮷' などの文字が正しく読み込まれません)

置き換えは、一方向です

置き換えた文字列を、元の文字エンコーディングへ書き戻しても、元のバイト列には戻りません。

上の例の置き換え後の文字列を、Shift_JIS へ書き戻すと、以下のようになります。

$fixed | Set-ProbedContent .\back_sjis.txt -Encoding shift_jis -NoNewline
Show-Bytes .\back_sjis.txt
8E 52 93 63 3F 3F 98 59 0D 0A 8D B2 93 A1 FB FC 0D 0A
元のバイト列 置き換え後 Shift_JIS へ書き戻した結果 見え方
F0 40 𠮷 3F 3F(??) 壊れたことが分かる
F0 41 髙 FB FC(IBM 拡張文字の位置) 正しく見えるが、元の位置ではない

𠮷 は Shift_JIS(CP932)に無い文字なので、? になります。Unicode の基本の範囲の外にある文字(サロゲートペア)なので、? が 2 つになります。

髙 は CP932 の IBM 拡張文字として存在するので、外字の領域ではなく、そちらの位置に書かれます。文字としては正しいのですが、バイト列は元と違います。

「字の意味を得ること」と「バイト列を保存すること」は、両立しません。 どちらを取るかの判断を利用者に残すことが、介入しないという方針の目的です。

別のコードページへ書くと、意味が変わることがあります

私用領域の文字を、別のコードページで書き込んだ場合も、.NET の動作に従います。

私用領域を持たないコードページ(US-ASCII など)へ書き込むと、? になります。

注意が必要なのは、私用領域を持つ別の国のコードページへ書き込む場合です。

日本語の外字として読み込んだ U+E000 を、韓国語の CP949 で書き込むと、C9 A1 になります。これは CP949 のユーザー定義領域、つまり韓国語の外字の位置です。

エラーにはなりません。1.2.0 の Convert-ProbedContent で変換しても、U+E000 は CP949 で表現できる文字なので、そのまま変換されます。

バイト列としては往復できますが、「日本の会社の外字」が「韓国語の外字の位置」に移っただけで、意味は何も保存されていません。

私用領域の文字は、別の文字エンコーディングへ変換しても、符号位置は保存されますが、意味は環境に依存したままです。

存在しない文字の扱い(外字以外)

外字とは別に、文字エンコーディングに存在しない文字の扱いも、このライブラリは .NET の既定の動作に従います。

書き込み

変換先の文字エンコーディングに存在しない文字は、コードページによって、以下のどちらかになります。

コードページ 動作 例
20127(US-ASCII)など ? に置き換える é → ?
932 などの旧マルチバイト 似た文字に置き換える(ベストフィット)。無ければ ? é → e

ベストフィットによる置き換えは、書き込んだ結果から検出できません。

Set-ProbedContent などの書き込み系のコマンドは、この既定の動作に従います。一方、1.2.0 で追加した Convert-ProbedContent は、文字を失う変換を行わないので、é を含むファイルを Shift_JIS へ変換しようとすると、エラーにして変換しません。

私用領域の文字は、それを持つコードページへ書き込む場合、ベストフィットの対象になりません(似た文字が存在しないからです)。上で書いた可逆性は、この性質によって成り立っています。

読み込み

解釈できないバイト列は、コードページによって、? や別の文字に置き換えて読み込まれます。日本語のコードページでは ・(U+30FB)になる場合があります(上で書いた cp51932 の例です)。

置き換えを避けるには

置き換えを許容できない場合は、書き込み先を UTF-8 などの Unicode の文字エンコーディングにしてください。Unicode ではすべての文字を表現できるので、置き換えは起きません。

限界

  • 私用領域の文字は、対応する外字フォントが無い環境では、正しい字形で表示されません
  • 私用領域の文字を別の文字エンコーディングへ変換しても、符号位置は保存されますが、意味は環境に依存したままです
  • このライブラリは、私用領域の文字が含まれていることを利用者に通知しません。必要な場合は、上の例のように [-] で検出してください
  • 将来、特定の文字集合(HKSCS など)に基づく置き換えを提供する場合も、明示的に指定したときだけ動く機能とし、既定の動作は変えません

香港の Big5(HKSCS)の外字が、実際にどう読み込まれ、どう往復するかは、以下の記事で解説しています。

SnowStack.EncodingProbe 1.2.0 解説 — ファイル出力・変換コマンドと世界の言語への対応

付録 — 往復の検証方法

以下の 2 つの検査を、Windows 11(PowerShell 5.1 / 7.6.6)、Ubuntu 24.04(PowerShell 7.6.5)、macOS 15.7(PowerShell 7.6.4)で実行し、すべての環境で件数を含めて同じ結果を得ています。

PowerShell 7.x で実行する場合は、先に以下の 1 行で CodePagesEncodingProvider を登録してください(SnowStack.EncodingProbe.PowerShell を Import-Module している場合は、モジュールが登録済みです)。

[Text.Encoding]::RegisterProvider([Text.CodePagesEncodingProvider]::Instance)

バイト列の側からの往復

2 バイトの全組み合わせのうち、私用領域の文字として読み込まれるものについて、書き戻して元のバイト列に戻るかを調べます。

foreach ($cp in 932, 950, 949, 936) {
    $e = [Text.Encoding]::GetEncoding($cp)
    $ng = 0; $total = 0
    foreach ($l in 0x81..0xFE) { foreach ($t in 0x40..0xFE) {
        $b = [byte[]]($l, $t); $s = $e.GetString($b)
        if ($s.Length -eq 1 -and [int][char]$s -ge 0xE000 -and [int][char]$s -le 0xF8FF) {
            $total++
            if ([BitConverter]::ToString($e.GetBytes($s)) -ne [BitConverter]::ToString($b)) { $ng++ }
        }
    } }
    "cp{0}: PUA {1} 件中 往復失敗 {2} 件" -f $cp, $total, $ng
}
コードページ 私用領域へ読み込まれる 2 バイト 往復失敗
932 1880 0
936 2149 0
949 188 0
950 6217 0

文字の側からの往復

私用領域の全符号位置について、書き込んで読み込んだときに元の文字に戻るかを調べます。

$codePages = 932, 936, 949, 950, 20932, 51932, 51936, 51949, 51950, 54936

foreach ($cp in $codePages) {
    try { $e = [Text.Encoding]::GetEncoding($cp) }
    catch { "cp{0}: 実行環境が提供していない" -f $cp; continue }

    $total = 0; $ng = 0; $skip = 0
    foreach ($u in 0xE000..0xF8FF) {
        $c = [char]$u
        $b = $e.GetBytes([string]$c)
        if ($b.Length -eq 1 -and $b[0] -eq 0x3F) { $skip++; continue }
        $total++
        $back = $e.GetString($b)
        if ($back -ne [string]$c) { $ng++ }
    }
    "cp{0,-6}: PUA {1,4} 件中 往復失敗 {2} 件(符号化不可 {3} 件)" -f $cp, $total, $ng, $skip
}
コードページ 書き込める符号位置 往復失敗
932 1884 0
936 2150 0
949 189 0
950 6218 0
20932 1882 0
51932 2 0
51936 2150 0
51949 6 0
51950 実行環境が提供していない —
54936 6400 0

二つの表の件数の差は、未定義の 1 バイト(FF など)によるものです。

51932 と 51949 の件数が少ないのは、この二つがユーザー定義領域を私用領域として扱わず、未定義の 1 バイトだけが私用領域に対応しているためです。

(.ps1 ファイルに保存して Windows PowerShell 5.1 で実行する場合は、UTF-8(BOM 付き)で保存してください)

関連資料