AppKitで自前のテキストエディターを組むと、文字列の長さを数える単位が2つになります。SwiftのString.countは拡張書記素クラスターの個数で、NSRangeが数えるのはUTF-16の符号単位の個数です。
// 日本語のかなや漢字では動いてしまう
let lineRange = NSRange(location: currentLocation, length: line.count)
// NSRange が数えているのはこちら
let lineRange = NSRange(location: currentLocation, length: (line as NSString).length)
この取り違えを見つけて直したのに、文字が消える症状は止まりませんでした。最小の再現コードを書いて切り分けたところ、消していたのは別の場所でした。
VigilareFloating Reminders for macOSKeep your Apple Reminders always on top with a floating window for macOS. Works in fullscreen apps, with quick actions, list filtering, and a built-in Markdown editor.1文字の数え方が2つある
SwiftのCharacterは拡張書記素クラスターです。
“Every instance of Swift’s
Charactertype represents a single extended grapheme cluster.”
同じページに、NSStringとの食い違いが名指しで書いてあります。
“The count of the characters returned by the
countproperty isn’t always the same as thelengthproperty of anNSStringthat contains the same characters. The length of anNSStringis based on the number of 16-bit code units within the string’s UTF-16 representation and not the number of Unicode extended grapheme clusters within the string.”
NSStringのドキュメントにも同じことが書いてあります。
“The number of UTF-16 code units in the receiver. This number includes the individual characters of composed character sequences, so you cannot use this property to determine if a string will be visible when printed or how long it will appear.”
AppKitのテキストまわりのAPIはこの単位で動いています。NSTextStorageはNSMutableAttributedStringのサブクラスなので、属性を付ける範囲も、NSLayoutManagerへ伝える位置も、UTF-16で数えたNSRangeです。
日本語だけで試すと、この取り違えは見つからない
取り違えたまま日本語を打っても、何も起きません。実際に数えるとこうなります。
日本語のタイトル count= 8 utf16= 8
これは本文です。 count= 8 utf16= 8
中文标题 count= 4 utf16= 4
한국어 제목 count= 6 utf16= 6
タスク 📝 count= 5 utf16= 6 ← ずれる
がいこく(NFD) count= 4 utf16= 5 ← ずれる
𠮟る count= 2 utf16= 3 ← ずれる
👨👩👧👦 count= 1 utf16=11 ← ずれる
🇯🇵 count= 1 utf16= 4 ← ずれる
行1\r\n行2 count= 5 utf16= 6 ← ずれる
基本多言語面に収まるかな・漢字・簡体字と、合成済みのハングルは、1つの書記素が1つの符号単位で表せるので一致します。ずれるのは、書記素が2つ以上の符号単位でできているときだけです。
- 基本多言語面の外にある文字(絵文字、CJK拡張Bの漢字など)
- 結合文字で書いた濁点や半濁点。
がをか+U+3099で表したもの - ZWJでつないだ絵文字、国旗
- ハングルを字母に分解したもの
- CRLF
つまり「CJKで壊れる」という言い方は当たっていません。壊れるかどうかを決めているのは言語ではなく、1つの書記素が何個の符号単位でできているかです。ふだん打つ日本語はそこに当てはまりません。この取り違えを日本語のテストで踏もうとしても踏めない、というのが最初に分かったことでした。
属性の範囲がずれると、行末に属性が付かない
行ごとにフォントサイズを変える処理でcountを長さに使うと、範囲が手前で終わります。次の行の開始位置も同じ分だけずれるので、ずれは行をまたいで累積します。
濁点を結合文字で書いた文字列を読み込ませて、各位置に付いたフォントサイズを並べたものがこれです。1行目を24pt、2行目以降を14ptにする処理にかけています。
入力 "がいこくのざっじゅつ\nパスポート" の濁点・半濁点を
すべて結合文字にしたもの(decomposedStringWithCanonicalMapping)
count=16 utf16=21
count で範囲を作った場合
24,24,24,24,24,24,24,24,24,24,12,14,14,14,14,14,12,12,12,12,12
UTF-16 で範囲を作った場合
24,24,24,24,24,24,24,24,24,24,24,24,24,12,14,14,14,14,14,14,14
1行目の24ptが3つ手前で切れ、末尾の5つには意図した属性が付いていません。見た目には、行の途中から急に文字が小さくなります。同じ文字列を合成済みのまま渡すと、2つの配列は一致します。
ただし、ここで文字は消えません。属性が付かなかった位置には、NSTextStorageがレイアウトの前に走らせる属性の補正でフォントが入ります。属性を付けなかった絵文字の位置を読むとAppleColorEmojiの12ptが入っていました。属性が外れるだけで、描画自体はされます。
追加したテストは、直す前のコードでも通っていた
修正と一緒に、日本語・中国語・韓国語・混在・絵文字のテストを足していました。そのアサーションを二十件あまり、修正前の実装に当て直したところ、全部通りました。
理由は入力ではなく、見ている位置でした。どのテストも、行の先頭と、改行の次の位置しか読んでいません。ずれが出るのは行末なので、そこを読むアサーションが1件も無ければ、範囲が手前で切れていても判定は変わりません。絵文字を含むテストだけは実際に範囲が1つ短くなっていましたが、短くなった行末を確かめるアサーションが書かれていませんでした。
入力に日本語を並べたことで確かめた気になっていた、というのがこのテストの状態でした。壊れる入力を用意するだけでは足りず、壊れる位置を読まないと、テストは修正の前後で同じ結果を返します。
消えていたのはグリフで、原因は別の場所だった
NSTextStorageのサブクラスは、文字列を書き換えたあとに親クラスへ変更を伝えます。
“These primitives should perform the change, then call
edited(_:range:changeInLength:)to let the parent class know there are changes.”
そのchangeInLengthの説明はこうです。
“The number of characters added to or removed from
oldRange.”
ここでいうcharactersもUTF-16の符号単位です。実装はこうなっていました。
public override func replaceCharacters(in range: NSRange, with str: String) {
beginEditing()
backingStore.replaceCharacters(in: range, with: str)
edited(
[.editedCharacters, .editedAttributes], range: range, changeInLength: str.count - range.length
)
endEditing()
}
str.countは書記素の個数、range.lengthは符号単位の個数です。単位の違うものを引いています。絵文字を1つ挿入すると、実際には2つ増えているのに「1つ増えた」と伝わるので、NSLayoutManagerが持っている長さが1つずつ足りなくなります。
2つの箇所を別々に切り替えて、1書記素ずつ挿入しながらNSLayoutManagerにレイアウトさせた結果がこれです。
changeInLength | 属性の範囲 | storage.length | グリフ数 |
|---|---|---|---|
| 書記素の個数 | 書記素の個数 | 10 | 9 |
| 書記素の個数 | 符号単位の個数 | 10 | 9 |
| 符号単位の個数 | 書記素の個数 | 10 | 10 |
| 符号単位の個数 | 符号単位の個数 | 10 | 10 |
属性の範囲をどちらにしてもグリフ数は変わりません。効いているのはchangeInLengthだけでした。最初に直したのは属性の範囲のほうなので、目に見えていた症状はそのまま残っていたことになります。

このとき標準エラーには!!! _NSLayoutTreeLineFragmentRectForGlyphAtIndex invalid glyph indexが出ます。NSLayoutManagerが、持っていないグリフ番号を参照したという警告です。
同じ取り違えはURLの検出にもありました。NSDataDetectorへ渡す範囲をcountで作っていたため、行の前のほうに絵文字が混ざると検出結果の末尾が切れます。
"メモ 📝 https://example.jp"
count で範囲を作った場合 → https://example.j
UTF-16 で範囲を作った場合 → https://example.jp
リンクは張られるのに、飛び先が1文字短くなります。表示は壊れないので、気づくのは踏んだ人です。
IMEの変換が最初に戻るのは、属性の話ではなかった
もうひとつ、変換中に表示が最初の状態へ戻る症状がありました。当時の記録には「空行に属性が付かないせいだ」と書いてあったのですが、これは成り立ちません。
長さ0のNSRangeにaddAttributesを呼んでも、何も起きません。NSTextStorageのサブクラスで呼び出しを数えると、setAttributesは一度も呼ばれず、edited(_:range:changeInLength:)も呼ばれませんでした。空行に属性を付けること自体ができないので、それを原因にはできません。
実際に変換を消していたのはNSViewRepresentableのほうでした。
func updateNSView(_ nsView: NSScrollView, context: Context) {
guard let textView = nsView.documentView as? NSTextView else { return }
if textView.string != text {
textView.string = text
}
}
textView.stringへの代入は、変換中の文字列を確定させます。setMarkedTextで変換中の状態を作ってから代入すると、同じ文字列を代入してもhasMarkedText()がfalseに変わりました。逆に、変換中の範囲へaddAttributesを呼んでもhasMarkedText()はtrueのままです。属性を付けても変換は消えません。
“The receiver removes any marking from pending input text and disposes of the marked text as it wishes. The text view should accept the marked text as if it had been inserted normally.”
代入が走る経路はループになっていました。
1打鍵ごとにtextDidChangeが飛び、ViewModelの@Publishedが動き、SwiftUIがビューを更新し、updateNSViewが代入します。textView.string != textの比較で防いでいるつもりでも、ループのどこかで文字列が変わって戻ってくれば代入が走り、そこで変換が消えます。
止め方は、変換中はSwiftUIへ通知を上げないことでした。
func textDidChange(_ notification: Notification) {
guard let textView = notification.object as? NSTextView else { return }
parent.text = textView.string
// IME変換中はonTextChangeを呼ばない
if !textView.hasMarkedText() {
parent.onTextChange?(textView.string)
}
}
バインディングへの書き戻しは変換中も続けています。書き戻す値がtextView.stringそのものなので、updateNSViewの比較が一致して代入が走りません。ループを断ち切っているのは、@Publishedを動かすonTextChangeのほうです。
なお、textView.fontを設定していないという別の漏れもありました。未設定だとtypingAttributesのフォントはHelveticaの12ptになります。変換中に入る文字はここのフォントで表示されるので、確定するまで大きさが違って見えます。設定すれば見え方は落ち着きますが、変換が消える症状はこれでは止まりません。
壊れる入力を先に決める
この種の取り違えは、差分を読んでも見つかりません。単位が違うだけで型は同じIntなので、コンパイラーは何も言いません。日本語を打っても症状が出ないので、自分で触って確かめても見つかりません。
再現コードに入れておく文字列を決めておくと早いです。
- サロゲートペアを含む文字。絵文字と、
𠮟のようなCJK拡張Bの漢字 - 結合文字で書いた濁点。
がをか+U+3099にしたもの - ZWJでつないだ絵文字と、国旗
- CRLF
- それらを行末に置いたもの。行頭だけ読むテストは通ってしまう
呼び出し箇所を洗い出すほうは、まだ自動化できていません。
rg -n 'NSRange\(location:.*length: \w+\.count' --type swift
rg -n 'changeInLength: .*\.count' --type swift
正規表現では.countがStringのものか配列のものかを区別できないため、当たりを付けたあとに型を読む作業が残ります。配列のcountを長さに使う書き方は正しいので、機械的に全部潰すこともできません。型を見て判定するには構文解析まで降りる必要があり、そこはまだ手を付けていません。

