JavaScript textLength as halfWidth (半角としてのテキスト幅)

JavaScript で半角幅としての文字列長を調べた件です。日本語のみ考慮してます。

# 課題

input type="text" の size や textarea の cols に文字数にて横表示幅を指定すると、これらは半角が前提なので、全角文字が入ると困った挙動になります。

```html:
<meta charset="utf-8">
<input type="text" value="ABC" size="3">
<input type="text" value="赤青黄色" size="4">
<textarea cols="4">赤青黄色</textarea>
<textarea style="white-space: nowrap" cols="4">赤青黄色</textarea>
```

![inputtext.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1798/0f10690a-91a8-e101-4bb6-8420b9ff9062.png)

text は見切れますし、textarea は自動改行されます。自動改行阻止すると当然のように textarea も見切れます。
(プロポーショナルフォントなので実は ABC も微妙に足りませんが、大体は見えてるのでスルーで)

# 対応

全角1文字は半角の2文字としてカウントする処理があれば良さそうです。
具体的には、以下のようにカウント分けします。

| 種類 | 文字例 | length as harf width |
|---|---|---|
| 記号   | +-~ | 1 (x 3 文字 = 3)|
| アルファベット | ABCXYZ | 1 (x 6文字 = 6)|
| 半角カナ |  ｱｲｳｴｵ | 1 (x 5文字 = 5)|
| 全角カタカナ | アイウエオ | 2 (x 5文字 = 10)| | 
| 全角ひがらな | いろは | 2 (x 3文字 = 6) | 
| 漢字    |赤青黄  | 2 (x 3文字 = 6) |
| 異体字 | 葛󠄀城市 | 2 (x 3文字 = 6) |
| 絵文字  | 😄  | 2 |
| 合成絵文字 | 👨‍👩‍👧‍👦 | 2 |

# コードベース

大さっぱに以下の方針で行きます。

- 半角、もしくは全角では無いと判断できる文字はカウント +1
- それ以外は多分全角だろうからカウント +2 
- 更に必要に応じて特殊なコード対応。

```js:
function lengthAsHalfWidth(text) {
    let len = 0;
    for (const t of text) {
        const cp = t.codePointAt(0);
        if (半角、もしくは全角ではないと判断できる文字) {
            len += 1;  // 半角
        } if else (特殊なコード) {
            // コードに応じた何かの処理
        } else {
            len += 2;  // きっと全角
        }
    }
    return len;
}
```

ちなみに以下のようにするとうまく行きません。絵文字の章で説明します。

```js:
    for (let i = 0; i < text.length; i++) {
        const cp = text[i].codePointAt(0);
```

あと、charCodeAt を使うとコードが 2 byte に収まらない場合(例えば異体字セレクタ 0xE0100)、2つに分かれた値を見て判断する必要があり面倒です。このエントリでは、codePointAt を使います。

```js:
> for (c of Array.from("葛󠄀")) { console.log(c.charCodeAt(0), c.charCodeAt(1)) }
33883 NaN
56128 56576
> for (c of Array.from("葛󠄀")) { console.log(c.codePointAt(0)) }
33883
917760
```

917760 は16進数だと 0xE0100 で、異体字セレクタです。

# ASCII 記号/数字/アルファベット

```js:
if ((0x00 <= cp) && (cp < 0x7f)) {
    len += 1;  // ASCII 記号/数字/アルファベット
} else { 
...
```

0x20(スペース)〜 0x7E"~" の範囲に絞っても良いですが、それをしても範囲外(だいたい制御文字)が全角としてカウントされるだけなので。いっそのこと ASCII の前半を全てカバーで。

- http://www12.plala.or.jp/mz80k2/electronics/ascii/ascii.html

ASCII コードを 8bit のまま 0xA1 〜 0xDF を使って日本語のカタカナや記号を表す JIS X 0201 規格がありますが、Unicode に対応する領域があると聞かないので、スルーします。

# 半角カナ

SJIS で多用していた半角カナは Unicode にもあります。

```js:
if ((0xff61 <= cp) && (cp < 0xffa0)) {
    len += 1;  // 半角カナ
} else { 
...
```

半角カナは、昔の 2ch AAや、あとガラケー時代の文字を再現するのに必須ですね。。(๑>◡<๑)

# SJIS半角予約領域

- http://www5d.biglobe.ne.jp/~noocyte/Programming/CharCode.html#Sjis-Difference

Unicode には私的利用領域があり、いわゆる外字を割り当てるのですが、SJIS に半角文字として将来(大昔にとっての将来)使うかもしれない予約領域があり、それとのマッピングを意識する場合、以下のようになります。

```js:
if ((0xf8f0 <= cp) && (cp < 0xf8f4)) {
    len += 1;  // 半角文字の予約エリア
} else { 
...
```

概念的には対応した方がすっきりするかもしれません。自分は一旦無視します。(テスト出来ないので)
実際にこの文字を見た事がある。という方がいれば、教えてください。

# 異体字

```js:
> Array.from("葛󠄀城市")
(4) ['葛', '󠄀', '城', '市']
```

この2つ目は異体字セレクタ(0xE0100)で、文字幅に影響しない為、無視します。

```js:
if (((0xfe00 <= cp) && (cp <= 0xfe0f)) ||
    ((0xe0100 <= cp) && (cp <= 0xe01fe))) {
   ;  // 異体字セレクタは幅0扱い
} else { 
...
```

# 絵文字

冒頭で i でループする方法が駄目と触れましたが、
実際に文字列を配列として参照すると、絵文字は2文字に分割されます。サロゲートペアが原因です。

```js:
> t = "赤青黄"; for (let i=0; i < t.length; i++) { console.log(t[i]) }
赤
青
黄
> "😄👀🌹".length
6
> t = "😄👀🌹"; for (let i=0; i < t.length; i++) { console.log(t[i]) }
�
�
�
�
�
�
```

Iterator がサロゲートペアに対応しているので、for of を使うか、Array.from(text) を通せば配列アクセスで問題なくなります。

```js:
> for (const c of "😄👀🌹") { console.log(c) }
😄
👀
🌹
> t = Array.from("😄👀🌹"); for (let i=0; i < t.length; i++) { console.log(t[i]) }
😄
👀
🌹
```

以下のサイトの解説が詳しいので参考にどうぞ

- JavaScript における文字コードと「文字数」の数え方
  - https://blog.jxck.io/entries/2017-03-02/unicode-in-javascript.html

# 絵文字修飾

異体字セレクタの絵文字版みたいなものです。
具体的にはこれ "👍🏻👍🏼👍🏽👍🏾👍🏿"

```
if ((0x1f3fb <= cp) && (cp <= 0x1f3ff)) {
     ;  // Emoji Modifier
```

# 合成文字

例えば、👨‍👩‍👧‍👦 のような合成絵文字は Array.from でも対応できません。

```js:
> "👨‍👩‍👧‍👦".length
11
> text = Array.from("👨‍👩‍👧‍👦"); for (const c of text) { console.log(c) }
👨

👩

👧

👦
```

空行が入っているのが気になります。

```js:
> "👨‍👩‍👧‍👦".length
11
> Array.from("👨‍👩‍👧‍👦")
(7) ['👨', '‍', '👩', '‍', '👧', '‍', '👦']
```

この絵文字のノリしろになっている '' は、Zero Width Joiner (ZWJ,0x200D) です。
なので、前の絵文字の分スペースを戻せば、辻褄は合いそうです。

```js:
} else if (cp === 0x200d) {  // ZWJ
    len -= 2;  //  合成絵文字のノリしろ
} else {
```

なお、正しく処理したい場合は、専用ライブラリを使うと良いでしょう。例えばこれとか。

- https://www.npmjs.com/package/graphemesplit

# まとめ

```js:
function lengthAsHalfWidth(text) {
    let len = 0;
    for (const c of text) {
        const cp = c.codePointAt(0);
        if ((0x00 <= cp) && (cp < 0x7f)) {
            len += 1;  // ASCII 記号/数字/アルファベット
        } else if ((0xff61 <= cp) && (cp < 0xffa0)) {
            len += 1;  // 半角カナ
        } else if (cp === 0x200d) {  // ZWJ
            len -= 2;  // 合成絵文字のノリしろ
        } else if (((0xfe00 <= cp) && (cp <= 0xfe0f)) ||
                   ((0xe0100 <= cp) && (cp <= 0xe01fe))) {
            ;  // 異体字セレクタは幅0扱い
        } else if ((0x1f3fb <= cp) && (cp <= 0x1f3ff)) {
            ;  // 絵文字修飾も幅0扱い
        } else {
            len += 2;  // きっと全角
        }
    }
    return len;
}
```

もし、ZWJ で巻き戻るのが全角だけでなく半角の可能性があるのなら、少し複雑ですが、こんな実装が思いつきます。

```js:
function lengthAsHalfWidth(text) {
    let len = 0;
    let width = 0
    for (const c of text) {
        const cp = c.codePointAt(0);
        if ((0x00 <= cp) && (cp < 0x7f)) {
            width = 1; // ASCII 記号/数字/アルファベット
        } else if ((0xff61 <= cp) && (cp < 0xffa0)) {
            width = 1;  // 半角カナ
        } else if (cp === 0x200d) {  // ZWJ
            width = -width;  // 合成絵文字のノリしろ
        } else if (((0xfe00 <= cp) && (cp <= 0xfe0f)) ||
                   ((0xe0100 <= cp) && (cp <= 0xe01fe))) {
            ;  // 異体字セレクタは幅0扱い
        } else if ((0x1f3fb <= cp) && (cp <= 0x1f3ff)) {
            ;  // 絵文字修飾も幅0扱い
        } else {
            width = 2;  // きっと全角
        }
        len += width;
    }
    return len;
}
```


# テスト


```js:
> lengthAsHalfWidth("XYZ");
3
> lengthAsHalfWidth("漢字ですデス");
12
> lengthAsHalfWidth("😄😇");
4
> lengthAsHalfWidth("ｱｲｳｴｵ");
5
> lengthAsHalfWidth("👨‍👩‍👧‍👦");
2
```

# 追記

絵文字の異体字に対応していませんでした。課題で。
"👍🏻👍🏼👍🏽👍🏾👍🏿"


# 参考

- 半角を判別する
   - http://var.blog.jp/archives/76281025.html
- JavaScriptでの絵文字の扱われ方を知っていますか？
   - https://tech.smartcamp.co.jp/entry/emoji-problem-on-javascript
- JavaScript における文字コードと「文字数」の数え方
  - https://blog.jxck.io/entries/2017-03-02/unicode-in-javascript.html
- 半角を判別する
  - http://var.blog.jp/archives/76281025.html
- Full-width half-width conversion
  - https://learn-language.tokyo/en/half-width-full-width
- JavaScriptで絵文字とサロゲートペアと結合文字とgrapheme clusterを正しく扱うのに少し苦労した話
  - https://qiita.com/amanoese/items/68bb9999829de4323302
- JavaScript: 異体字セレクターを考慮して文字数を求める
  - https://blog.sarabande.jp/post/79238170880
- [https://ja.wikipedia.org/wiki/異体字セレクタ](https://ja.wikipedia.org/wiki/%E7%95%B0%E4%BD%93%E5%AD%97%E3%82%BB%E3%83%AC%E3%82%AF%E3%82%BF)
- 字形選択子（異体字セレクタ） Variation Selectorsの文字一覧 - 1 Unicode U+FE00～U+FE0F(65025文字目～65040文字目)
  - https://0g0.org/category/FE00-FE0F/1/
- [https://www.weblio.jp/wkpja/content/その他の記号及び絵記号_その他の記号及び絵記号の概要](https://www.weblio.jp/wkpja/content/%E3%81%9D%E3%81%AE%E4%BB%96%E3%81%AE%E8%A8%98%E5%8F%B7%E5%8F%8A%E3%81%B3%E7%B5%B5%E8%A8%98%E5%8F%B7_%E3%81%9D%E3%81%AE%E4%BB%96%E3%81%AE%E8%A8%98%E5%8F%B7%E5%8F%8A%E3%81%B3%E7%B5%B5%E8%A8%98%E5%8F%B7%E3%81%AE%E6%A6%82%E8%A6%81)