クラウド Mac で検証する iOS 文字ハイライトの Unicode 境界

クラウド Mac で検証する iOS 文字ハイライトの Unicode 境界

クラウド Mac で検証する iOS 文字ハイライトの Unicode 境界

検索結果に表示される名前で、英語なら問題ないのに、ダイアクリティカルマーク付きの文字や肌の色を指定した絵文字ではハイライトがずれることがあります。原因は多くの場合、フォントではなく、範囲の扱いに関する取り決めにあります。サーバーはバイト単位のオフセットを返し、UI は UTF-16 単位のオフセットを受け取り、業務ロジックは Swift の文字数で切り出している、といった不一致です。クラウド Mac でまずプレーンテキストの検証を一通り実行すれば、リモートデスクトップで何度も操作するより原因を絞り込みやすくなります。

この方法は、検索結果の抜粋、リッチテキストのマークアップ、チャット内容の選択範囲に使えます。必要なのはインストール済みの Xcode コマンドラインツールだけで、ネットワークサービスは不要です。XcNode の専有物理ノードでも、テスト入力とツールチェーンの記録をセットで残しておきましょう。

まずオフセットの単位を明確にする

Swift の String.count が数えるのは拡張書記素クラスタ、つまり Character です。utf16.count は UTF-16 のコード単位、utf8.count はバイト数を数えます。この 3 つは互いに置き換えられません。絵文字は、基本となる絵文字、肌の色の修飾子、ゼロ幅接合子から構成されることがあり、見た目は 1 文字でも、内部では複数のコード単位を使います。

Foundation の NSRange は、文字列 API では通常 UTF-16 単位で扱われます。text.count を範囲の有効な上限として使ったり、整数を使って String.Index を直接進めたりしてはいけません。API の仕様には、少なくとも単位、元のテキストのバージョン、空の範囲の扱い、マッチしなかった場合の表現を明記します。

変換に成功しても、インデックスを作成できたことしか確認できません。範囲の両端が、ユーザーに見える文字全体の境界にあるとは限りません。ハイライト表示には、さらに境界の検証が必要です。

元のテキストを変更したら、以前の範囲は無効にします。置換、大文字・小文字の変換、削除を行った新しい文字列に、古い検索結果の範囲を適用してはいけません。

小さなスクリプトで文字の途中の範囲を拒否する

次の内容を highlight-check.swift として保存します。まず未検出と範囲外を処理し、次にインデックスへ変換し、最後に両端が Character の境界にあるかを確認します。長さの判定に引き算を使うことで、先に開始位置と長さを足した場合に起こり得る整数オーバーフローも避けられます。

import Foundation

func highlightRange(
    _ value: NSRange,
    in text: String
) -> Range<String.Index>? {
    let total = text.utf16.count
    guard value.location != NSNotFound,
          value.location >= 0,
          value.length >= 0,
          value.location <= total,
          value.length <= total - value.location,
          let range = Range(value, in: text) else {
        return nil
    }

    var boundaries = Set(text.indices)
    boundaries.insert(text.endIndex)
    guard boundaries.contains(range.lowerBound),
          boundaries.contains(range.upperBound) else {
        return nil
    }
    return range
}

let text = "A👩🏽‍💻e\u{301}Z"
let emoji = (text as NSString).range(of: "👩🏽‍💻")
precondition(highlightRange(emoji, in: text) != nil)
precondition(highlightRange(NSRange(location: 1, length: 1),
                           in: text) == nil)

let accent = "e\u{301}"
precondition(highlightRange(NSRange(location: 0, length: 1),
                           in: accent) == nil)
precondition(highlightRange(NSRange(location: 0, length: 2),
                           in: accent) != nil)
precondition(highlightRange(NSRange(location: NSNotFound, length: 0),
                           in: text) == nil)
precondition(highlightRange(NSRange(location: text.utf16.count,
                                   length: 1),
                           in: text) == nil)

xcrun swift highlight-check.swift を実行し、終了コードがゼロなら、これらのアサーションは通過しています。失敗すると実行が停止します。チェックを無効にする最適化オプションは使わないでください。正式なテストターゲットに組み込む際は、失敗した入力を特定しやすいよう、サンプル番号を含むテストアサーションに置き換えます。

ここでは、エディタの挿入位置として必要になる場合があるため、文字境界上の空の範囲を許可しています。検索ハイライトだけに使う呼び出し側では、検索ループが先へ進めなくなるのを防ぐため、長さゼロのマッチを別途拒否する必要があります。

検索では元のテキストの座標を保つ

ダイアクリティカルマークを無視するために、まずそれを取り除いたコピーを作り、そのコピーのオフセットを元のテキストに適用するのは、よくある誤りです。たとえば、ダイアクリティカルマーク付きの合成済み文字と、文字に結合ダイアクリティカルマークを続けた表現は、同じ文字を表せても UTF-16 の長さが同じとは限りません。

製品の要件として大文字・小文字やダイアクリティカルマークを無視する必要があるなら、元のテキストを直接検索できます。こうすれば、返される範囲は元の文字列に対応したままになります。

let source = "Cafe\u{301}"
let match = source.range(
    of: "cafe",
    options: [.caseInsensitive, .diacriticInsensitive],
    locale: Locale(identifier: "en_US_POSIX")
)
guard let match else {
    fatalError("Expected a search match")
}
let nsRange = NSRange(match, in: source)
precondition(highlightRange(nsRange, in: source) != nil)
precondition(String(source[match]) == source)

ロケールを固定しているのは、このサンプルの再現性を確保するためです。あらゆる言語の検索に共通する正解というわけではありません。名前の検索でダイアクリティカルマークを無視するか、トルコ語の大文字・小文字をどう扱うかは、製品の仕様として決め、それぞれのサンプルを追加します。単語分割や翻字が必要な場合は、元のテキストとの明示的な対応関係を管理し、長さの差から位置を推測してはいけません。

失敗した入力を回帰テストの必須チェックにする

少なくとも、通常の漢字、サロゲートペア、結合ダイアクリティカルマーク、肌の色を指定した絵文字、接合子でつながった絵文字、国旗のシーケンスを網羅します。各種類について、全体がマッチする場合と、内部で切れてしまう場合の両方を確認します。さらに、先頭と末尾の空の範囲、末尾を超える範囲、未検出の結果も追加します。

有効な範囲ごとに往復変換を確認します。Swift の範囲に変換した後、NSRange(range, in: text) で戻し、結果が元と一致することを検証します。実際に選択されたテキストにもアサーションを設け、「範囲は有効だが、誤った位置にマッチしている」ケースを見逃さないようにします。

長いテキストでは、マッチのたびにすべての境界を走査するのは適切ではありません。同じ不変のテキストに対して境界の集合を一度だけ作り、複数のマッチで共有できます。テキストを更新したら、集合を作り直します。異なる文字列の間でキャッシュを使い回してはいけません。

UI の受け入れ確認は表示層の検証を補うものにする

プレーンテキストの必須チェックを通過したら、シミュレータで固定の検索サンプルを開きます。ハイライトがダイアクリティカルマークを分断していないこと、絵文字シーケンスの一部だけに色が付いていないこと、改行後もマークが元のテキストに対応していることを確認します。目視確認は描画の問題を扱うもので、範囲のアサーションの代わりにはなりません。

非同期検索の結果が返ってきたら、まずリクエストに対応する元のテキストのバージョンを照合し、その後でハイライトを適用します。入力欄の内容がすでに変わっていれば、古い結果は破棄します。また、検索範囲と最終的な表示範囲は区別します。表示層で文字全体まで範囲を広げる必要がある場合も、業務上の判定には元のマッチを残し、拡張した範囲は描画だけに使います。

最終的な合格基準は「クラッシュしない」ことではありません。単位が明確で、範囲が有効で、文字の境界を壊さず、マッチしたテキストが正しいことです。実際に失敗した文字列を固定サンプルに加えておけば、次に検索アルゴリズムを変更した際、どの挙動が変わったのかをすぐに把握できます。

よくある質問

NSRange の変換が成功すれば安全ですか?

いいえ。変換だけでなく、両端が Character の境界かどうかを text.indices と endIndex で確認します。

アクセントを除いた文字列の位置を元の文字列に使えますか?

そのままでは使えません。UTF-16 の長さが変わる可能性があります。元の文字列で検索するか、明示的な位置対応表を用意します。

開発作業に最適な専有クラウドMacを選ぶ

Apple Silicon搭載の物理ノードを、仮想マシンではなく専有でご利用いただけます。日・週・月・四半期単位でレンタルでき、機種と料金は公開しています。実際の空き状況はコンソールにリアルタイムで表示されます。

クラウドMacをレンタル 機種・レンタル期間を見る