[Excel/VBA]半角幅を1として、全角・半角混在の文字列幅を取得したい

# はじめに

記事中、間違っている箇所があるかもしれません。すみません。

※文字列の表示幅を「文字列長」というべきか「文字列幅」というべきかしばし迷いましたが、特に区別していません。「幅」も「長」も同じ意味で用いています。



# 発端

ふだんExcel/VBAを使っていて、全角文字と半角文字が混在している文字列の半角換算の文字列長を取得したい、と思うことがあります。

文字列長を取得する関数はもともと`Len()`や`LenB()`が用意されていますが、これらの関数は通常、全角・半角にかかわらず、一文字をそれぞれ1字あるいは2バイトとしてカウントします。

```vb
str = "おにぎり!!"
Debug.Print Len(str)    ' =>  6
Debug.Print LenB(str)   ' => 12

```

この例でいえば、"おにぎり!!"から半角幅換算の10（全角文字×4字+半角文字×2字 = 2×4+1×2 = 10）という値を得たい、というわけです。今回、少し気になってなんとかならないものか調べてみました。


# そもそもなぜ全角と半角が同じバイト数になるのか？

この問題には、VBAの文字コードが関係してきます。VBAは内部文字コードをUnicode（UTF-16）としてもたせています。

UTF-16は、基本的には一文字を16ビット（2バイト）としてとりあつかう方式であるため、`LenB()`でバイト数を調べると、下表のように全角文字と半角文字のいずれもが2バイトになるわけです。

|   | バイト列（16進数） | バイト数 |
|:--:|:--:|:-:|
| a（半角） | 61 00 | 2 |
| あ（全角） | 42 30 | 2 |

Unicodeの話は[ここ](http://equj65.net/tech/charcode/)がわかりやすいです。私なりに理解したことをまとめると、

* Unicodeはあつかえる文字をとり決めたもの。また、収載文字ひとつひとつにコードポイントという固有の番地をふっている。
* UTF-8やUTF-16は、Unicodeが収録する文字を、実際にどのようなバイト列として記録・表現するか、という符号化方法を指す。UTF-8は半角文字が1バイトで全角文字が3バイトだが、UTF-16はいずれも2バイト。Unicodeの実装、実現方法の違い。
* コードポイントは、Unicodeの文字一覧表上の各文字の番地であり、UTF-8やUTF-16などで実際に表現されるバイト列とは別モノ。Unicodeのコードポイント`Y+0061`番の文字`a`を、UTF-8では`61`、UTF-16では`61 00`というバイト列として表現する、という対応になる（下表）。

| 文字 | コードポイント | UTF-8 | UTF-16 |
|:-:|:-:|:-:|:-:|
| a  | U+0061 | 61 | 61 00 |
| あ | U+3042 | E3 81 82 | 42 30 |
| 亜 | U+4E9C | E4 BA 9C | 9C 4E |

* UTF-16では、2バイト分の整数値をそのまま並べるビッグエンディアンと、ひっくり返すリトルエンディアンがあり、VBAはリトルエンディアンでとりあつかう。

ということになります。



# 解決策

で、実際に半角換算の文字列長を得るにはどうすればいいのか？ということで調べると、ふたとおりの方法がすでに提示されていました。いずれも[こちらのサイト](http://www.moug.net/tech/exvba/0140023.html)で紹介されています。

## 1.StrConv()でShift_JISに変換した結果をLenB()で調べる

対象の文字列を一度Shift_JISに変換してから、そのバイト数を調べるという方法です。

```vb
str = "おにぎり!!"
Debug.Print LenB(StrConv(str, vbFromUnicode))   ' => 10
```

Shift_JISは半角文字を1バイト、全角文字を2バイトとしているので、この性質を利用することで文字幅を調べることができます。Shift_JISに載っていない文字は、UTF-16から変換する際に文字化けするので調べられないのでは…と思ったら、どういうわけかうまい具合に文字幅を取得できるようです。

```vb
str = "妳"   ' 中国語の「貴女」
Debug.Print LenB(StrConv(str, vbFromUnicode))
    ' => Shift_JISに収載されていないため化けるが、2を得られる
```

[こちら](http://note.phyllo.net/?eid=1106051)や[こちら](http://www.239-programing.com/excel-vba/func/func023.html)などでも紹介されています。


## 2.ワークシート関数のLENB()を使う

VBA関数の`LenB()`とは別にワークシート関数の`LENB()`というのがあります。`LENB()`は、全角文字を2バイト、半角文字を1バイトとしてカウントします。

```vb
=LENB("おにぎり!!")   ' => 10
```

VBA関数の`LenB()`とは文字化けした場合の挙動が異なり、文字化け箇所は1バイトになります。

```vb
=LENB("妳")   ' => 1
```

この関数は`Application.WorksheetFunction`から選択できないため、実際には`Application.Evaluate()`を使って書くことになります。

```vb
Debug.Print Application.Evaluate("LenB(""おにぎり!!"")")   ' => 10
Debug.Print [LenB("おにぎり!!")]   ' こう書くこともできる
```

[こちらのサイト](http://hensa40.cutegirl.jp/archives/2206)でも紹介されています。

ちなみに、ワークシート関数の`LENB()`はExcel2007で追加された関数のようです。



# ちょっとこだわってみる

現実的な対応としては、解決策1の方法でとりたてて困ることもなさそうなんですが、ちょっと気になったことがありました。
上で紹介したふたとおりの方法は、いずれもShift_JISの文字幅を得る方法です。Unicodeには収載されているがShift_JISにはない文字の場合は、UTF-16→Shift_JISの変換によってわざわざ化けさせることになります。例えば、上の例で使っている"妳"という字は、UnicodeにはありますがShift_JISにはありません。
そこで、別の方法がないか少しこだわってみました。

[こちらのサイト](http://thom.hateblo.jp/entry/2015/09/29/213222)によると、`String`型は`Byte`型の配列と等しいらしく、文字列を`Byte()`配列に代入することで実際にバイト値を得ることができました。

```vb
Dim bytes() As Byte
bytes = "あ"   ' 長さ2の配列になる
Debug.Print Hex(bytes(0)); Hex(bytes(1)) ' =>  42 30
```

これにヒントを得ていろいろ試したところ、UTF-16ではどうやら文字のバイト値がUnicodeのコードポイントと一致するらしいことに気がつきました。VBAはリトルエンディアンなので、一文字ごとにバイト列の上位と下位を反転させればコードポイントが得られます。上の例でいえば、`42 30`をひっくり返した`30 42`が、文字"あ"のコードポイントになります。

ならば、文字幅を得たい文字列中の各文字のバイト値が、半角文字のコードポイントの範囲内かどうかで半角・全角を判断すればよいことになります。半角文字が半角英数と半角記号、半角カナを指すと考えれば、半角文字は、

* 半角英数と記号… 0x0020 ~ 0x007E
* 半角カタカナ… 0xFF61 ~ 0xFFA0

におさまりそうです。

ということで、次のような文字列幅取得プロシージャを書いてみました。

```vb
' 文字列の半角換算幅を得る
Public Function GetWidth(ByVal str As String) As Long
    Dim ret_length As Long
    Dim i          As Long
    Dim char_      As String

    ret_length = 0
    
    For i = 1 To Len(str)
        char_      = Mid$(str, i, 1)
        ' 一文字ごとに半角文字かどうかを調べ、半角であれば1を、
        ' そうでなければ全角文字とみなし、2をret_lengthに加える。
        ret_length = ret_length + IIf(IsHalfWidthCharacter(char_), 1, 2)
    Next i
    
    GetWidth = ret_length
End Function

' 半角文字か判定する
Private Function IsHalfWidthCharacter(ByVal str As String) As Boolean
    Dim bytes()    As Byte 
    Dim byte_value As Long  ' 文字のバイト値
    
    bytes      = str
    byte_value = bytes(1) * 16 ^ 2 + bytes(0)

    Select Case byte_value
    Case &H20& To &H7E&, &HFF61& To &HFF7E&

        IsHalfWidthCharacter = True
    Case Else
        IsHalfWidthCharacter = False
    End Select
End Function

```

これを実際に使ってみると、次のようになります。

```vb
Debug.Print GetWidth("おにぎり!!")   ' => 10
Debug.Print GetWidth("妳")          ' =>  2
```

とりあえずこれでめでたし、かな…。



# おわりに

調べたりいろいろ動作を試したり、時間はかかりましたが勉強にはなったと思います。

サロゲートペアは私の理解を超えるため特にふれませんでしたが、試してみたらやはりうまくあつかえないみたいですね。`LenB()`や`Len()`でも難しいようです。

そもそも「UTF-16ではどうやら文字のバイト値がUnicodeのコードポイントと一致するらしい」という理解が正しいのかも疑問ですが、目下支障はないのでよし！[Wikipedia](https://ja.wikipedia.org/wiki/UTF-16)を見る限りは、この理解でおそらく大丈夫そうですが…（サロゲートペアをのぞく）。