Edited at

テキスト中の文字の列と行の取得

More than 1 year has passed since last update.

テキストエディタは大抵、現在のカーソル位置(列、行)をステータスバーとかに表示できる。

そして、やはり大抵は、いわゆる全角文字(漢字とか)は、いわゆる半角文字(アルファベットとか)の2文字分列を占める。

水平タブは(エディタの設定によるが)一定文字数に合わせるように占めるサイズが変わる。

この辺を加味した、改行を含む文字列における文字の列と行を取得する処理を C# のコードで考える。


全角 / 半角

Shift JIS で使う文字に必ず収まるなら

    // 半角なら 1, 全角なら 2

int n = Encoding.GetEncoding("Shift_JIS").GetByteCount("字");

でいけるが、そうでない場合は面倒...

Unicode標準の附属書の一つに「東アジアの文字幅」があり、そのデータを使用することである程度決められる。

http://www.unicode.org/Public/UCD/latest/ucd/EastAsianWidth.txt


EastAsianWidth.cs

using System;

using System.Collections.Generic;
using System.Linq;
using System.Globalization;
using System.IO;

namespace eawtest
{
public class EastAsianWidth
{
public enum Kinds
{
Undefined = 0,
Neutral = 1,
Anbiguous = 2,
Harfwidth = 3,
Wide = 4,
Fullwidth = 5,
Narrow = 6,
}

private class MapData
{
public int Start;
public int End;
public int Kind;
}

private class MapDataComparer : IComparer<MapData>
{
public int Compare(MapData x, MapData y)
{
return Comparer<int>.Default.Compare(x.Start, y.Start);
}
}

public static EastAsianWidth CreateFromUcd(string path)
{
// 読み込み
var srcmap = new List<MapData>();
var kindTexts = new List<string>(new string[] { "_", "N", "A", "H", "W", "F", "Na", });
var lines = File.ReadAllLines(path);
var data = default(MapData);
var comparer = new MapDataComparer();
foreach (var parts in lines.Select(l => l.Split('#')[0].Trim()).Select(l => l.Split(';')).Where(p => p.Length == 2))
{
int idx = parts[0].IndexOf("..");
data = new MapData();
data.Start = int.Parse((idx > 0 ? parts[0].Substring(0, idx) : parts[0]), NumberStyles.HexNumber);
data.End = int.Parse((idx > 0 ? parts[0].Substring(idx + 2) : parts[0]), NumberStyles.HexNumber);
if ((data.Kind = kindTexts.IndexOf(parts[1])) < 0)
{
throw new Exception(string.Format("unknown kind. {0}", parts[1]));
}
int pos = srcmap.BinarySearch(data, comparer);
srcmap.Insert(pos < 0 ? ~pos : pos, data);
}
int maxCodePoint = srcmap.Max(x => x.End) + 1;
srcmap.Add(new MapData() { Start = maxCodePoint, End = maxCodePoint, Kind = 0, });

// undefined も含めたマッピング構築
var map = new List<int>();
int lastStart = 0;
var prev = srcmap[0];
if (prev.Start > 0)
{
map.Add(0 + (int)Kinds.Undefined);
lastStart = prev.Start;
}
for (int i = 1; i < srcmap.Count; i++)
{
var next = srcmap[i];
if (prev.Kind == next.Kind && (prev.End + 1) == next.Start)
{
prev = next;
continue;
}
map.Add((lastStart << 4) + prev.Kind);
if ((prev.End + 1) != next.Start)
{
map.Add(((prev.End + 1) << 4) + (int)Kinds.Undefined);
}
prev = next;
lastStart = next.Start;
}
map.Add((maxCodePoint << 4) + (int)Kinds.Undefined);

return new EastAsianWidth(map.ToArray(), maxCodePoint);
}

private int maxCodePoint = 0;
private int[] map = null;

private EastAsianWidth(int[] map, int maxCodePoint)
{
this.map = map;
this.maxCodePoint = maxCodePoint;
}

private int GetKindInternal(int cp)
{
if (cp < 0 || cp >= this.maxCodePoint)
{
return (int)Kinds.Undefined;
}
int target = (cp << 4) | 0x0F;
int pos = Array.BinarySearch<int>(this.map, target);
int val = this.map[pos < 0 ? (~pos - 1) : pos];
return val & 0x0F;
}

public Kinds GetKind(int cp)
{
return (Kinds)GetKindInternal(cp);
}

public Kinds GetKind(char c)
{
return GetKind((int)c);
}

public bool IsZenkaku(int cp)
{
Kinds kind = GetKind(cp);
return kind == Kinds.Fullwidth || kind == Kinds.Wide;
}

public bool IsZenkaku(char c)
{
return IsZenkaku((int)c);
}

public bool IsHankaku(int cp)
{
return !IsZenkaku(cp);
}

public bool IsHankaku(char c)
{
return !IsZenkaku(c);
}

public void Save(string filePath)
{
using (var fs = new FileStream(filePath, FileMode.Create, FileAccess.Write, FileShare.None))
{
var bw = new BinaryWriter(fs);
bw.Write(this.maxCodePoint);
bw.Write(this.map.Length);
for (int i = 0; i < this.map.Length; i++)
{
bw.Write(this.map[i]);
}
}
return;
}

public static EastAsianWidth Load(string filePath)
{
int size = 0;
var data = default(int[]);
using (var fs = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.None))
{
var br = new BinaryReader(fs);
size = br.ReadInt32();
int len = br.ReadInt32();
data = new int[len];
int prev = 0;
for (int i = 0; i < data.Length; i++)
{
int val = br.ReadInt32();
if (prev > val)
{
// 昇順になっていない場合は異常と判断
throw new InvalidDataException();
}
data[i] = val;
prev = val;
}
}
return new EastAsianWidth(data, size);
}
}
}


しかし、特性値 A (曖昧)の文字は「文脈によって半角として扱うか全角として扱うか変わる」となっており、どう扱うかは必要に応じて決めるしかない...

先のコードでは特性値 A は半角としている。


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

通常 char が unicode の1文字にあたるが、サロゲートペアや結合文字は2個以上の char で1個の文字を表す。

System.Globalization.StringInfo.GetTextElementEnumerator メソッドを使うと、サロゲートペアや結合文字を加味した1文字分の string を順次切り出し列挙してくれる TextElementEnumerator を得られるので、これを使用させてもらうのが方法として容易かと考える。

1文字分の文字列が char 2個以上からなる(Length > 1 の)とき、

Length == 2 && char.IsSurrogatePair(text, 0) 

が真ならサロゲートペア、そうでなければ結合文字、と考えられる。

サロゲートペアの場合、単純に「東アジアの文字幅」特性から判断しうる。

結合文字の場合、後述のサンプルコードでは1文字目に合成されるものとして、単に1文字目の特性だけ見ているが、表示も持つシステムにあっては結合できない場合にどう表示するか(個々に表示するのか等)も加味する必要があるだろう。。。


改行

Windows系(0x0D 0x0A)、Unix系(0x0A)、古いMacOS系(0x0D)とまちまちだが、TextReader.GetLine() はこの辺を加味し、末尾改行コードを含まない文字列を返してくれるので、後述のサンプルコードでは改行判定はこれに任せるようにし、見かけ '\n' (0x0A) が存在しているように扱っている。


水平タブ

1タブを半角スペース何個で扱うかと、水平タブの出現時の列がわかっていれば、単に計算で導出できる。


コントロールコード

水平タブと改行コード以外は 幅 0 扱いで...


サンプルコード

TextReader をソースとして、1文字ずつ読み進める Cursor クラス


cursor.cs

using System;

using System.Globalization;
using System.IO;

namespace eawtest
{
public class Cursor
{
private EastAsianWidth eaw;
private TextReader sr;
private int column;
private int row;
private string current;
private EastAsianWidth.Kinds kind;
private TextElementEnumerator line;
private int tabspace;

public string Current
{
get { return this.current; }
}

public int Column
{
get { return this.column; }
}

public int Row
{
get { return this.row; }
}

public EastAsianWidth.Kinds Kind
{
get { return this.kind; }
}

public Cursor(TextReader sr, EastAsianWidth eaw)
{
this.eaw = eaw;
this.sr = sr;
this.current = string.Empty;
this.column = 1;
this.row = 1;
this.tabspace = 4;
}

private int GetWidth(char c)
{
return this.eaw.IsZenkaku(c) ? 2 : 1;
}

private int GetWidth(int cp)
{
return this.eaw.IsZenkaku(cp) ? 2 : 1;
}

public bool Next()
{
if (line == null)
{
string linetext = this.sr.ReadLine();
if (linetext == null)
{
this.current = string.Empty;
return false;
}
this.line = StringInfo.GetTextElementEnumerator(linetext);
}
if (!this.line.MoveNext())
{
this.line = null;
this.row++;
this.column = 1;
this.kind = EastAsianWidth.Kinds.Anbiguous;
this.current = "\n";
return true;
}
string elm = this.line.GetTextElement();
if (elm.Length > 1)
{
if (elm.Length == 2 && char.IsSurrogatePair(elm, 0))
{
// サロゲートペアの場合
int cp = char.ConvertToUtf32(elm[0], elm[1]);
this.column += this.GetWidth(cp);
this.kind = eaw.GetKind(cp);
}
else
{
// 結合文字は、1文字目だけで判定
this.column += this.GetWidth(elm[0]);
this.kind = eaw.GetKind(elm[0]);
}
}
else
{
char c = elm[0];
this.kind = eaw.GetKind(c);
if (c == '\t')
{
this.column = ((((this.column - 1) / this.tabspace) + 1) * this.tabspace) + 1;
}
else if (!char.IsControl(c))
{
this.column += this.GetWidth(c);
}
}
this.current = elm;
return true;
}
}
}


これを使用したサンプル


Program.cs

using System;

using System.Collections.Generic;
using System.IO;

namespace eawtest
{
class Program
{
static void Main(string[] args)
{
var eaw = EastAsianWidth.CreateFromUcd("EastAsianWidth.txt");

string src =
"昨日食べた𩸽は旨かった💜\r" +
"「わ」に濁点\t「わ\u3099」。\n" +
"国際通貨記号は ¤ \r\n" +
"Объект";

using(var sr = new StringReader(src))
{
var cursor = new Cursor(sr, eaw);
int col = cursor.Column;
int row = cursor.Row;
while(cursor.Next())
{
string elm = cursor.Current;
Console.WriteLine("\"{0}\" : {1} : ({2},{3})", ToStr(elm), cursor.Kind, col, row);
col = cursor.Column;
row = cursor.Row;
}
}
}

private static string ToStr(string elm)
{
var codes = new List<string>();
foreach (char c in elm)
{
codes.Add(string.Format("\\u{0:X04}", (int)c));
}
return string.Join(", ", codes);
}
}
}



2017/12/18 改行コードのところの記述が間違っていたので修正...