openlang

String.length и len считают байты

Обрезанное сообщение об ошибке разрезало имя пополам. Код оставлял первые 40 байт через String.sub msg 0 40, имя ближе к концу было корейским, и разрез пришёлся на середину трёхбайтовой последовательности UTF-8. В терминале на месте последнего слога появился символ замены.

String.length в OCaml считает байты. Модуль String описывает строку как неизменяемую последовательность байтов, и String.sub принимает позиции в байтах. В Go len(s) то же самое, и s[:40] режет по байтам. Ни то ни другое не ошибка. Они просто считают не то, что считает читатель сообщения.

Локальное исправление в том, чтобы сдвинуть разрез назад к началу символа. В Go для этого хватит короткого цикла на utf8.RuneStart.

func truncate(s string, max int) string {
	if len(s) <= max {
		return s
	}
	i := max
	for i > 0 && !utf8.RuneStart(s[i]) {
		i--
	}
	return s[:i]
}

Здесь s[i] - первый байт, который будет отброшен. Если с него начинается символ, разрез чистый, по i. Если это байт продолжения, цикл идёт влево, пока не найдёт начало разрезаемого символа, и режет перед ним. На сообщении, которое кончается на 김다현, с лимитом внутри 다 получается текст до 김 включительно и корректный UTF-8, а s[:40] оставлял два бездомных байта.

В OCaml String.get_utf_8_uchar декодирует символ по индексу, а Uchar.utf_decode_length говорит, на сколько шагнуть, так что тот же обход выходит на пару строк длиннее и читается примерно так же. Версия на Go проходит ещё пару строк, чисто ASCII и такую, где лимит уже приходится на границу символа, и обе обрезаются ровно по лимиту.

Результат остаётся корректным UTF-8, и больше я этому исправлению не приписываю. Останется ли целым символ, который видит человек, уже другой вопрос.

Разговор продолжается 6

Следуя доводам
  1. Ссылка на ответ

    Вынесите это в .mli как val truncate : max_bytes:int -> string -> string, и пусть текст для показа режет только эта функция. Тогда никто потом не вернётся потихоньку к String.sub.

  2. Ссылка на ответ

    Я пишу на JavaScript, и там та же ошибка, только единица поменьше. .length и slice считают кодовые единицы UTF-16, поэтому name.slice(0, 40) может разорвать не последовательность UTF-8, а суррогатную пару, и в конце останется одинокий суррогат. Отступ к границе кодовой точки это исправляет. Но 🇰🇷 так целым не останется. Флаг состоит из двух кодовых точек, четырёх единиц UTF-16 и одной графемы, и разрез, который уважает только кодовые точки, проходит прямо через его середину. Для сообщения об ошибке это, наверное, терпимо. Для имени лучше резать по графемам.

  3. Ссылка на ответ

    RuneStart не нужен. Обход строки через range и так даёт начала рун.

    if len(s) <= max {
    	return s
    }
    end := 0
    for i := range s {
    	if i > max {
    		break
    	}
    	end = i
    }
    return s[:end]

    На корректном UTF-8 результат тот же, и на один импорт меньше.

  4. Ссылка на ответ

    Ломается и на символе потише. Имя, которое кончается на e и U+0301, при лимите ровно между ними теряет акцент. Цикл на utf8.RuneStart оставляет e, выбрасывает комбинирующий акцент и возвращает совершенно корректный UTF-8. Версия на OCaml делает то же самое, как и должна, обе смотрят только на кодовые точки. Имя выходит без ошибки и с неправильным написанием, и в одном смысле это хуже символа замены. Никто не заметит.

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

  5. Ссылка на ответ

    Многоточие лечит симптом. Нормализуйте в NFC перед обрезкой, и e с U+0301 станет одной кодовой точкой é, так что обрезка по кодовым точкам их уже не разделит. Для этого имени сработает. Для любой графемы уже нет, у многих комбинирующих последовательностей нет готовой составной формы. В стандартной библиотеке Go нормализатора тоже нет. Придётся подключать golang.org/x/text/unicode/norm.

Другие стороны того же вопроса

  1. `isdigit()` пропустил надстрочную двойку, которую `int()` не принял

    Поле количества в небольшой форме проверяет s.isdigit(), а потом вызывает int(s). Кто-то вставил ² и получил ValueError ровно…

    Ответы 4python
  2. Разумно ли по умолчанию искать имена по ключу NFC?

    Так, пользователь вставляет имя, а в конце у него e и следом U+0301. Потом он набирает то же имя руками, и клавиатура даёт U+…

  3. Ключи поиска NFC и подписи JCS

    Подпись усложняет вопрос kim_dh про ключи поиска NFC, где отправленное имя отделено от ключа поиска NFC. В JSON-нагрузке есть…

    Ответы 4typescript