Posted at

UTF-8時代の環境依存文字チェック ~そこに文字はあるか~

More than 3 years have passed since last update.

 UTF-8が標準的に扱われるようになり、また、③やⅢなどの過去には機種依存文字だったものが規格で追認された今、文字化けに悩まされることは少なくなってきました。

 しかし、スマホの普及に伴い絵文字という刺客が現れます。

 そんな時代の環境依存文字チェックについて考えていきたいと思います。

 なお、以下の内容はフォーカス外です。


  • バックスラッシュと円記号問題

  • 新JISにおける例示字形の変更168文字(例:葛飾区の葛とか)

  • 全角チルダ問題


全部文字のせいだ

 使えない文字について、「機種依存文字」や「環境依存文字」などの呼び方がありますが、UTF-8で扱っている世界の中では「フォント依存文字」問題ということになるのではないでしょうか。

 ここの見出しは「全部フォントのせいだ」のほうが正解かと思いますが、ノリで文字としています(関東ローカルネタでしょうか?)


答えは文字に聞け

 ある文字コードが表示可能かどうかは、フォントファイルにお尋ねして、表示できれば表示できるし、表示できなければ環境依存文字と判定することができそうです。

 (フォントによって文字(グリフ)が違うということは想定していません。)

 Javaでいうと、Font#canDisplay() や Font#canDisplayUpTo() で表示の可不可を調べることができます。canDisplay()はある一文字についてtrue/false、canDisplayUpTo()は文字列について何番目がダメかを返します。

 cancanDisplay() の利用例を示します。


test1.java

    public static void main(String args[]) throws FontFormatException, IOException {

Font font = Font font = new Font("メイリオ", Font.PLAIN, 12);

String str = "①ⅱⅢ";
for (int i = 0; i < str.length(); i++) {
char c = str.charAt(i);
boolean canDisp = font.canDisplay(c);

System.out.println(c + " " + Integer.toHexString(c) + " " + canDisp);
}
}



サロゲートペア

 当初、UNICODEは16bitに世界中の文字を詰め込むことを目標にして作られました。が、欧米が思っていたよりも極東の国々の文字が多すぎるため、16bitじゃ収まらないことに気づきました。そこで登場するのがサロゲートペアです。この場合、2文字分の32bitで1文字をあらわします。

 日本語のよくある文字を扱っている分にはサロゲートペアを意識しなくてもそれなりに動きますが、一部の文字を扱うときにはちゃんとサロゲートペアに対応したコードを書かなければなりません。

 たとえば、「吉野家」なんですが、「吉」の字は正しくは「𠮷」(←あなたの環境で見えますか?)です。上が士でなくて土なので「つちよし」と呼ばれていたりする文字です。

 上記の test1.java もサロゲートペアを考慮していないため、変な動きをします。「𠮷」が二文字になり、それぞれ表示できない判定となってしまいます。

 サロゲートペアの対応方法についてはいくつかあるのですが、今回は、IBM developerWorks® Java による Unicode サロゲートプログラミング を参考に、実用性と速度のバランスがいい 例1-5 をお手本に対応してみました。


test2.java

    public static void main(String args[]) throws FontFormatException, IOException {

Font font = new Font("メイリオ", Font.PLAIN, 12);
String str = "𠮷野家";

char[] arrayChar = str.toCharArray(); // charに変換
int len = arrayChar.length;

for (int i = 0, codePoint; i < len; i += Character.charCount(codePoint)) {
codePoint = Character.codePointAt(arrayChar, i);
boolean canDisp = font.canDisplay(codePoint);
char[] chs = Character.toChars(codePoint);

System.out.print(chs);
System.out.print(" ");
System.out.print(Integer.toHexString(codePoint));
System.out.print(" ");
System.out.println(canDisp);
}
}



そこに文字はあるか

 クロスプラットフォーム環境では、想定される環境で使用するフォントを集めてきて、それらのどれかひとつでも表示できない(canDisplay()がfalseを返す)場合は環境依存文字であると判定すればよさそうです。


test3.java

    public static void main(String args[]) throws FontFormatException, IOException {

List<Font> fonts = new ArrayList<Font>();
fonts.add(new Font("MS 明朝", Font.PLAIN, 12));
fonts.add(new Font("メイリオ", Font.PLAIN, 12));
fonts.add(Font.createFont(Font.TRUETYPE_FONT, new File("ipaexg.ttf")));

String str = "𠮷野家";

char[] arrayChar = str.toCharArray(); // charに変換
int len = arrayChar.length;

for (int i = 0, codePoint; i < len; i += Character.charCount(codePoint)) {
codePoint = Character.codePointAt(arrayChar, i);
boolean canDisp = true;
for (Font font : fonts) {
if (!font.canDisplay(codePoint)) {
canDisp = false;
break;
}
}
char[] chs = Character.toChars(codePoint);

System.out.print(chs);
System.out.print(" ");
System.out.print(Integer.toHexString(codePoint));
System.out.print(" ");
System.out.println(canDisp);
}
}



生のフォントファイルの代わりに環境依存文字辞書ファイルを用意する

 実運用では、実行環境上にフォントファイルを置くことは無駄が多いですし、ライセンスの問題も発生しそうです。そこで、環境依存文字の辞書ファイルをあらかじめ作っておくことを考えます。

 サロゲートペアでつかうコードポイントは 0~0x10FFFF であることが分かっていますので、その範囲をあらかじめすべて調べます。そして、連続して利用可能なコードポイントを from,to で出します。


test4.java

    public void createValidCharacterCsv() throws IOException, FontFormatException {

List<Font> fonts = new ArrayList<Font>();
fonts.add(new Font("MS 明朝", Font.PLAIN, 12));
fonts.add(new Font("メイリオ", Font.PLAIN, 12));
fonts.add(Font.createFont(Font.TRUETYPE_FONT, new File("ipaexg.ttf")));
fonts.add(Font.createFont(Font.TRUETYPE_FONT, new File("D:/font/MAC/ヒラギノ角ゴシック W3/001.TTF")));

File file = new File("D:/font/そこに文字はあるか.csv");
BufferedWriter bw = new BufferedWriter(new FileWriter(file));

boolean bPrevDisp = false;
int prevCodePoint = -1;
for (int codePoint = 0; codePoint < 0x10FFFF; codePoint++) {
boolean bDisp = true;
for (Font font : fonts) {
if (!font.canDisplay(codePoint)) {
bDisp = false;
break;
}
}
if (bPrevDisp != bDisp) {
if (bDisp) {
prevCodePoint = codePoint;
} else {
bw.write(Integer.toHexString(prevCodePoint));
bw.write(",");
bw.write(Integer.toHexString(codePoint - 1));

// csvに文字も出力したい場合はコメントを外してください
// bw.write(",");
// bw.write(Character.toChars(prevCodePoint));
// bw.write(",");
// bw.write(Character.toChars(codePoint - 1));

bw.newLine();
}
bPrevDisp = bDisp;
}
}
bw.close();
}


このプログラムを動かすと、

9,a

d,d
20,7e
a0,b4
b6,109
10c,10f
.
.
.
2a61a,2a61a
2a6b2,2a6b2
2f818,2f818
2f877,2f877
2f8dc,2f8dc
2f8ed,2f8ed

というようなファイルが出力されます。

0x0~0x8 はダメ、0z9~0xa はOK、0xb~0xc はダメ、0x0d はOK・・・ という意味になります。


環境依存文字辞書ファイルを利用するプログラム

 先のプログラムの出力をもとに、boolean 配列を作ればいい話なのですが、1MBちょい消費します。

 メモリを節約するために from,to のままリストに持ち、調べるときは頭から全件検索させるアルゴリズムで実装してみました。


test5.java

import java.io.BufferedReader;

import java.io.FileReader;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;

public class Main {

List<CodePointFromTo> listValidCodePoints;

public static void main(String args[]) throws IOException {

Main main = new Main();

main.readValidCharacterCsv("D:/font/そこに文字はあるか.csv");

String str = "𠮷野家";

char[] arrayChar = str.toCharArray(); // charに変換
int len = arrayChar.length;

for (int i = 0, codePoint; i < len; i += Character.charCount(codePoint)) {
codePoint = Character.codePointAt(arrayChar, i);
boolean canDisp = main.canDisp(codePoint);
char[] chs = Character.toChars(codePoint);

System.out.print(canDisp);
System.out.print(" ");
System.out.print(chs);
System.out.print(" ");
System.out.print(Integer.toHexString(codePoint));

for (int j = 0; j < chs.length; j++) {
if ( j==0 ) {
System.out.print("(");
}
else{
System.out.print(",");
}
System.out.print(Integer.toHexString(chs[j]));
}
System.out.print(")");

System.out.println();
}

}

/**
* 使用可能コードポイントファイルを読み込みます.
* @param validCharacterCsv 使用可能コードポイントファイルのファイル名
* @throws IOException
*/

public void readValidCharacterCsv(String validCharacterCsv) throws IOException {

listValidCodePoints = new ArrayList<CodePointFromTo>();

FileReader fr = new FileReader(validCharacterCsv);
BufferedReader br = new BufferedReader(fr);

String line;
while ((line = br.readLine()) != null) {
String[] rows = line.split(",");

int from = Integer.decode("0x" + rows[0]);
int to = Integer.decode("0x" + rows[1]);
listValidCodePoints.add(new CodePointFromTo(from, to));
}

br.close();
}

/**
* コードポイントが使用可能かを返します.
* @param codePoint コードポイント
* @return 使用可否
*/

public boolean canDisp(int codePoint) {

for (CodePointFromTo cpft : listValidCodePoints) {
if (cpft.from <= codePoint && codePoint <= cpft.to) {
// 正しい文字の範囲内だ!
return true;
}
if (codePoint < cpft.from) {
// これ以上調べても無駄。
return false;
}
}
return false;
}

class CodePointFromTo {
int from;
int to;

public CodePointFromTo(int from, int to) {
this.from = from;
this.to = to;
}
}
}


 全件検索でも、実用上は問題ないと思います。ほとんどの文字は利用可能で、かつ前の方に偏って存在するからです。

 改善として、二分探索するのもアリだと思います。

 メモリ消費を気にしない場合は BitSet で管理してもよいでしょう。


まとめ


  • ある文字が表示可能かどうかはフォントに聞く。

  • クロスプラットホーム環境では、利用が想定されるフォントを用意し、それらすべてで表示可能な文字だけを利用可能とする。