本文へ移動
myowchick

全角半角変換にNFKCを使うと①が1になり㍑がリットルになる

全角半角変換が要るとき、まず思いつくのがString.prototype.normalize('NFKC')。1行で済むし、ありがちな入力ならだいたい期待どおりに動く。

ただ、これを通すと①が1になる。㍑はリットル、㈱は(株)、㌔はキロ。幅を揃えるつもりで呼んだ関数が、幅と関係のない畳み込みまで一緒にやっている。

どこまで巻き込むのかを実際に数えて、対象を絞った変換表で書き直した。

NFKCは幅以外も畳む

'①㍑㈱㌔Ⅷ'.normalize('NFKC')
// → '1リットル(株)キロVIII'

どのくらいの規模の話なのかが気になったので、全コードポイントを回して数えた。

let changed = 0
let width = 0
const inWidth = (c) => (c >= 0xff01 && c <= 0xff5e) || (c >= 0xff61 && c <= 0xff9f) || c === 0x3000

for (let c = 0; c <= 0x10ffff; c += 1) {
  if (c >= 0xd800 && c <= 0xdfff) continue
  const s = String.fromCodePoint(c)
  if (s.normalize('NFKC') !== s) {
    changed += 1
    if (inWidth(c)) width += 1
  }
}

結果。

数えたもの件数
NFKCで変化するコードポイント4965
うち幅に関係する範囲(U+FF01-U+FF5E、U+FF61-U+FF9F、U+3000)158
幅と関係なく変わるもの4807

幅の変換だと思って呼んでいる操作の97%は、幅と関係ない書き換えの担当だった。

巻き込まれるのは記号だけの話でもない。住所や商号をそのまま通すと普通に踏む。

入力NFKC
№1No1
℡03-1234TEL03-1234
㈱国会図書館(株)国会図書館
1234㎡1234m2
㋿元年令和元年
㊙秘
½1⁄2
filefile

方向も対象も選べない

NFKCは正規形へ寄せる操作なので、変換の向きを指定する口がそもそも無い。行き先は文字ごとに決まっていて、英数字は半角へ、カナは全角へ寄る。

'ABC'.normalize('NFKC') // → 'ABC'    全角から半角にはできる
'ABC'.normalize('NFKC') // → 'ABC'    半角から全角にはできない
'アイウ'.normalize('NFKC') // → 'アイウ'  半角から全角にはできる
'アイウ'.normalize('NFKC') // → 'アイウ'  全角から半角にはできない

「英数字だけ半角にして、カナは触らない」のような絞り込みもできない。やりたいことのうちNFKCで表現できるのは片側の一部でしかなくて、しかも余計なものが付いてくる。

英数字と記号はオフセットで済む

U+FF01からU+FF5Eまでは、U+0021からU+007Eまでと同じ並びになっている。差は0xFEE0で固定。

const WIDTH_OFFSET = 0xfee0

String.fromCodePoint('A'.codePointAt(0) - WIDTH_OFFSET) // → 'A'

この94文字を英数字と記号に分けるとき、記号のほうを範囲で書き下したくなるが、そこは引き算で定義したほうがいい。数えてみると94文字のうち62が英数字、残り32が記号で、その32はU+FF01-U+FF0F、U+FF1A-U+FF20、U+FF3B-U+FF40、U+FF5B-U+FF5Eの4ブロックに散っている。手で並べれば、いつか1ブロック落とす。

function isZenkakuAlnum(code: number): boolean {
  return (
    (code >= 0xff10 && code <= 0xff19) ||
    (code >= 0xff21 && code <= 0xff3a) ||
    (code >= 0xff41 && code <= 0xff5a)
  )
}

/** 記号は「U+FF01-U+FF5E のうち英数字でないもの」。英数字は alnum の担当なので外す */
function isZenkakuSymbol(code: number): boolean {
  return code >= 0xff01 && code <= 0xff5e && !isZenkakuAlnum(code)
}

引き算で書いておくと、担当が重ならないことと、94文字に穴が空かないことが同時に保証される。

スペースだけはこの並びの外にいるので、オフセットに巻き込むと壊れる。

String.fromCodePoint(0x20 + 0xfee0) // → '＀' U+FF00。全角スペースではない
' '.codePointAt(0).toString(16) // → '3000'

全角スペースはU+3000で、U+FF00は未割り当ての位置。特例として別に持つしかない。

半角カナの濁点は独立した1文字

ここが1:1の写像表で書けない理由になる。

Array.from('バ').length // → 2  U+FF8A U+FF9E
Array.from('バ').length // → 1  U+30D0

半角では濁点が独立した1文字を占めていて、全角では1文字に畳まれている。文字数が変わるので、方向によって処理の形が変わる。

半角から全角は、1文字先を覗いて濁点なら畳む。

for (let index = 0; index < chars.length; index += 1) {
  const char = chars[index]
  // 半角では濁点・半濁点が独立した 1 文字なので、後ろを覗いて 1 文字に畳む
  const combined = combineMark(char, chars[index + 1])

  if (combined !== undefined) {
    result.push(combined)
    index += 1
    continue
  }
  // ...
}

全角から半角は逆に、濁点付きの1文字を2文字へ分解する。表のエントリはカナ領域の63文字に、濁点付き21文字と半濁点付き5文字を足して89になる。

畳めない濁点の行き先

ア゙のように、濁点が付かない文字に濁点が続くことがある。畳めないので濁点を単独で残すことになるが、行き先が2つある。

toZenkaku('ア゙', ['katakana']) // → 'ア゛'  U+30A2 U+309B
'ア゙'.normalize('NFKC') // → 'ア゙'  U+30A2 U+3099

U+309Bは独立した1文字の濁点、U+3099は前の文字にくっつく結合文字。見た目はほとんど同じでも、文字数を数えたり後ろから1文字消したりすると挙動が割れる。全角の文字列を作るのが目的なら、独立した文字であるU+309Bのほうを選ぶ。

並行した文字列リテラルは静かにずれる

カナの変換表は、半角と全角の文字列リテラルを位置で対にして作っている。

const HANKAKU_KANA = '。「」、・ヲァィゥェォャュョッーアイウエオカキクケコサシスセソタチツテトナニヌネノハヒフヘホマミムメモヤユヨラリルレロワン゙゚'
const ZENKAKU_KANA =
  '。「」、・ヲァィゥェォャュョッーアイウエオカキクケコサシスセソタチツテトナニヌネノハヒフヘホマミムメモヤユヨラリルレロワン゛゜'

短く書けるが、片方から1文字落ちるとそこから先が全部1つずれる。試しに全角側の・を落として作り直すと、こうなった。

ヲ → ァ
ア → イ
ン → ゛
゚ → undefined

例外は出ない。末尾がundefinedになるまで、全部それっぽい別のカナに化ける。サンプルを何個か置くタイプのテストだと、運が悪いと素通りする。

なので全コードポイントを回すテストを1本ずつ置いた。

it('JIS X 0201 のカナ領域(U+FF61-U+FF9F)をすべて全角にできる', () => {
  for (let code = 0xff61; code <= 0xff9f; code += 1) {
    const char = String.fromCodePoint(code)

    expect(toZenkaku(char, ['katakana'])).not.toBe(char)
  }
})

it('U+FF01-U+FF5E をすべて英数字か記号のどちらかで半角にできる', () => {
  for (let code = 0xff01; code <= 0xff5e; code += 1) {
    const char = String.fromCodePoint(code)

    expect(toHankaku(char, ['alnum', 'symbol'])).toBe(String.fromCodePoint(code - 0xfee0))
  }
})

あとは、うっかり正規化を呼んだ実装に戻さないための番人として、この1本を置いてある。

it('NFKC で分解される合成文字を変えない', () => {
  expect(toHankaku('①㍑㈱㌔Ⅷ', ALL)).toBe('①㍑㈱㌔Ⅷ')
})

normalize('NFKC')を1行足した瞬間に落ちる5文字を並べてあるだけなんだけど、壊す対象がここに全部出るので、これで足りると思う。

NFKCは正規形へ寄せる操作で、幅はそのついでに揃うだけだった。4965個のうち158個、3%ぶんの仕事のために呼んで、残りの97%も一緒に受け取っている。向きも対象も選べないので、①を数字にされたくないなら最初から別のものを書くしかない。

書き直した側は0xFEE0のオフセットとカナの変換表とスペースの特例で足りる。そのかわり表を持つので、表のずれを拾う網羅テストが要る。1:1の写像に見えるところに、濁点のぶんだけ1:2が混ざっているのがこの変換の厄介なところで、方向によって処理の形まで変わる。

← 一覧へ戻る