Проверка границ Unicode в подсветке текста iOS на облачном Mac

Проверка границ Unicode в подсветке текста iOS на облачном Mac

Проверка границ Unicode в подсветке текста iOS на облачном Mac

В списке результатов поиска имена на английском подсвечиваются правильно, но стоит появиться буквам с диакритикой или эмодзи с модификатором тона кожи — подсветка смещается. Обычно дело не в шрифте, а в соглашении о единицах смещения: сервер возвращает смещения в байтах, интерфейс ожидает смещения в UTF-16, а прикладной код выделяет подстроку по количеству символов Swift. На облачном Mac проще сначала выполнить набор проверок на уровне текста, чем раз за разом воспроизводить проблему через удалённый рабочий стол.

Этот подход подходит для поисковых фрагментов, разметки форматированного текста и выделения содержимого чата. Достаточно уже установленных инструментов командной строки Xcode, сетевые сервисы не нужны. На выделенном физическом узле XcNode тестовые данные также стоит сохранять вместе со сведениями об использованном наборе инструментов.

Сначала зафиксируйте единицы смещения

В Swift String.count считает расширенные графемные кластеры, то есть значения Character; utf16.count — кодовые единицы UTF-16; utf8.count — байты. Эти величины не взаимозаменяемы. Эмодзи может состоять из базового символа, модификатора тона кожи и соединителя нулевой ширины: визуально это один символ, но внутри — несколько кодовых единиц.

В строковых интерфейсах Foundation диапазон NSRange обычно задаётся в единицах UTF-16. Нельзя считать text.count допустимой верхней границей диапазона или сдвигать String.Index напрямую на целое число. Документация интерфейса должна как минимум определять единицы измерения, версию исходного текста, правила для пустых диапазонов и способ обозначения отсутствующего совпадения.

Успешное преобразование означает лишь, что индексы удалось создать. Оно не гарантирует, что оба конца диапазона совпадают с границами целых, видимых пользователю символов. Для подсветки нужна дополнительная проверка.

После изменения исходного текста старые диапазоны должны считаться недействительными. Нельзя использовать прежние результаты поиска для разметки новой строки, в которой были выполнены замены, изменён регистр или удалены фрагменты.

Небольшой скрипт для проверки целостности символов

Сохраните следующий код в 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?

Нет. Через text.indices и endIndex дополнительно проверьте, что обе границы совпадают с границами целых Character.

Можно ли использовать смещения строки без диакритики?

Напрямую нельзя: преобразование может изменить длину UTF-16. Ищите в исходной строке с согласованными параметрами или храните явное соответствие индексов.

Выберите выделенный облачный Mac для разработки

Физические узлы Apple Silicon, не виртуальные машины. Аренда на день, неделю, месяц или квартал. Конфигурации и цены опубликованы; актуальная доступность отображается в консоли.

Арендовать облачный Mac Конфигурации и сроки аренды