Лучший способ сократить строку UTF8 на основе длины байта - PullRequest
14 голосов
/ 04 августа 2009

Недавний проект, призванный импортировать данные в базу данных Oracle. Это будет приложение C # .Net 3.5, и я использую библиотеку соединений Oracle.DataAccess для обработки фактической вставки.

Я столкнулся с проблемой, когда получал сообщение об ошибке при вставке определенного поля:

ORA-12899 Слишком большое значение для столбца X

Я использовал Field.Substring(0, MaxLength);, но все равно получил ошибку (хотя и не для каждой записи).

Наконец-то я увидел то, что должно было быть очевидно, моя строка была в формате ANSI, а поле было UTF8. Его длина определяется в байтах, а не в символах.

Это возвращает меня к моему вопросу. Каков наилучший способ обрезать мою строку, чтобы исправить MaxLength?

Мой код подстроки работает по длине символа. Существует ли простая функция C #, которая может разумно обрезать строку UT8 по длине в байтах (т.е. не обрабатывать половину символа)?

Ответы [ 9 ]

13 голосов
/ 29 июня 2014

Я думаю, что мы можем добиться большего успеха, чем наивный подсчет общей длины строки при каждом добавлении. LINQ - это круто, но он может случайно поощрить неэффективный код. Что если бы я хотел получить первые 80 000 байтов гигантской строки UTF? Это лот ненужного подсчета. «У меня есть 1 байт. Теперь у меня есть 2. Теперь у меня есть 13 ... Теперь у меня есть 52,384 ...»

Это глупо. Большую часть времени, по крайней мере в l'anglais, мы можем сократить точно на этом nth байте. Даже на другом языке мы находимся менее чем в 6 байтах от хорошей точки отсечения.

Итак, я собираюсь начать с предложения @ Oren, которое заключается в отключении старшего бита значения символа UTF8. Давайте начнем с сокращения прямо на n+1th байт и используем трюк Орена, чтобы выяснить, нужно ли нам сократить несколько байт раньше.

Три возможности

Если первый байт после обрезки имеет 0 в начальном бите, я знаю, что я обрезаю точно перед одиночным байтовым (обычным ASCII) символом и могу обрезать чисто.

Если у меня есть 11 после вырезки, следующий байт после вырезки - это начало многобайтового символа, так что это тоже хорошее место для вырезки!

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

То есть, хотя я хочу вырезать строку после n-го байта, если этот n + 1-й байт находится в середине многобайтового символа, при резании получится недопустимое значение UTF8. Мне нужно выполнить резервное копирование, пока я не доберусь до того, который начинается с 11 и обрезается непосредственно перед ним.

Код

Примечания: я использую такие вещи, как Convert.ToByte("11000000", 2), чтобы было легко определить, какие биты я маскирую (немного больше о битовой маскировке здесь ). Короче говоря, я & собираюсь вернуть то, что находится в первых двух битах байта, и вернуть 0 s для остальных. Затем я проверяю XX из XX000000, чтобы увидеть, если это 10 или 11, где это необходимо.

Я узнал сегодня , что C # 6.0 может фактически поддерживать двоичные представления , что круто, но мы пока будем использовать этот kludge, чтобы проиллюстрировать, что происходит.

* * * * * * * * * * * * * * * * * * * * * * PadLeft * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *

Итак, вот функция, которая обрежет вас до строки длиной n байт или наибольшее число меньше n, которая заканчивается "полным" символом UTF8.

public static string CutToUTF8Length(string str, int byteLength)
{
    byte[] byteArray = Encoding.UTF8.GetBytes(str);
    string returnValue = string.Empty;

    if (byteArray.Length > byteLength)
    {
        int bytePointer = byteLength;

        // Check high bit to see if we're [potentially] in the middle of a multi-byte char
        if (bytePointer >= 0 
            && (byteArray[bytePointer] & Convert.ToByte("10000000", 2)) > 0)
        {
            // If so, keep walking back until we have a byte starting with `11`,
            // which means the first byte of a multi-byte UTF8 character.
            while (bytePointer >= 0 
                && Convert.ToByte("11000000", 2) != (byteArray[bytePointer] & Convert.ToByte("11000000", 2)))
            {
                bytePointer--;
            }
        }

        // See if we had 1s in the high bit all the way back. If so, we're toast. Return empty string.
        if (0 != bytePointer)
        {
            returnValue = Encoding.UTF8.GetString(byteArray, 0, bytePointer); // hat tip to @NealEhardt! Well played. ;^)
        }
    }
    else
    {
        returnValue = str;
    }

    return returnValue;
}

Я изначально написал это как расширение строки. Просто добавьте обратно this перед string str, чтобы вернуть его в формат расширения, конечно. Я удалил this, чтобы мы могли просто продемонстрировать метод в Program.cs в простом консольном приложении для демонстрации.

Тест и ожидаемая производительность

Вот хороший тестовый пример с выводом, который он создает ниже, с написанием, ожидающим, что это будет метод Main в простом консольном приложении Program.cs.

static void Main(string[] args)
{
    string testValue = "12345“”67890”";

    for (int i = 0; i < 15; i++)
    {
        string cutValue = Program.CutToUTF8Length(testValue, i);
        Console.WriteLine(i.ToString().PadLeft(2) +
            ": " + Encoding.UTF8.GetByteCount(cutValue).ToString().PadLeft(2) +
            ":: " + cutValue);
    }

    Console.WriteLine();
    Console.WriteLine();

    foreach (byte b in Encoding.UTF8.GetBytes(testValue))
    {
        Console.WriteLine(b.ToString().PadLeft(3) + " " + (char)b);
    }

    Console.WriteLine("Return to end.");
    Console.ReadLine();
}

Вывод следует. Обратите внимание, что «умные кавычки» в testValue имеют длину три байта в UTF8 (хотя, когда мы записываем символы в консоль в ASCII, она выводит тупые кавычки). Также обратите внимание на вывод ? s для второго и третьего байтов каждой умной кавычки в выводе.

Первые пять символов нашего testValue являются одиночными байтами в UTF8, поэтому значения 0-5 байтов должны быть 0-5 символов. Затем у нас есть трехбайтовая умная цитата, которая не может быть включена полностью до 5 + 3 байтов. Конечно же, мы видим это при вызове 8. Наша следующая умная цитата выскакивает при 8 + 3 = 11, а затем мы возвращаемся к однобайтовым символам до 14.

 0:  0::
 1:  1:: 1
 2:  2:: 12
 3:  3:: 123
 4:  4:: 1234
 5:  5:: 12345
 6:  5:: 12345
 7:  5:: 12345
 8:  8:: 12345"
 9:  8:: 12345"
10:  8:: 12345"
11: 11:: 12345""
12: 12:: 12345""6
13: 13:: 12345""67
14: 14:: 12345""678


 49 1
 50 2
 51 3
 52 4
 53 5
226 â
128 ?
156 ?
226 â
128 ?
157 ?
 54 6
 55 7
 56 8
 57 9
 48 0
226 â
128 ?
157 ?
Return to end.

Так что это довольно забавно, и я как раз перед пятилетней годовщиной вопроса. Хотя описание битов в Орене имело небольшую ошибку, это точно уловка, которую вы хотите использовать. Спасибо за вопрос; аккуратный.

13 голосов
/ 04 августа 2009

Здесь возможны два решения: однострочный LINQ, обрабатывающий ввод слева направо, и традиционный for цикл, обрабатывающий ввод справа налево. Какое направление обработки быстрее, зависит от длины строки, разрешенной длины байта, а также от количества и распределения многобайтовых символов, и трудно дать общее предложение. Решение между LINQ и традиционным кодом у меня, вероятно, дело вкуса (или, может быть, скорости).

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

public static String LimitByteLength(String input, Int32 maxLength)
{
    return new String(input
        .TakeWhile((c, i) =>
            Encoding.UTF8.GetByteCount(input.Substring(0, i + 1)) <= maxLength)
        .ToArray());
}

public static String LimitByteLength2(String input, Int32 maxLength)
{
    for (Int32 i = input.Length - 1; i >= 0; i--)
    {
        if (Encoding.UTF8.GetByteCount(input.Substring(0, i + 1)) <= maxLength)
        {
            return input.Substring(0, i + 1);
        }
    }

    return String.Empty;
}
4 голосов
/ 04 августа 2009

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

Дополнительные сведения см. В разделе «Описание» статьи в Википедии.

3 голосов
/ 17 мая 2017

Укороченная версия ответа Раффина . Использует дизайн UTF8 :

    public static string LimitUtf8ByteCount(this string s, int n)
    {
        // quick test (we probably won't be trimming most of the time)
        if (Encoding.UTF8.GetByteCount(s) <= n)
            return s;
        // get the bytes
        var a = Encoding.UTF8.GetBytes(s);
        // if we are in the middle of a character (highest two bits are 10)
        if (n > 0 && ( a[n]&0xC0 ) == 0x80)
        {
            // remove all bytes whose two highest bits are 10
            // and one more (start of multi-byte sequence - highest bits should be 11)
            while (--n > 0 && ( a[n]&0xC0 ) == 0x80)
                ;
        }
        // convert back to string (with the limit adjusted)
        return Encoding.UTF8.GetString(a, 0, n);
    }
2 голосов
/ 04 августа 2009

Есть ли причина, по которой вам нужно объявить столбец базы данных в байтах? Это значение по умолчанию, но оно не особенно полезно по умолчанию, если набор символов базы данных имеет переменную ширину. Я бы настоятельно предпочел объявить столбец в терминах символов.

CREATE TABLE length_example (
  col1 VARCHAR2( 10 BYTE ),
  col2 VARCHAR2( 10 CHAR )
);

Это создаст таблицу, в которой COL1 будет хранить 10 байтов данных, а col2 - 10 символов. Семантика длины символов имеет гораздо больше смысла в базе данных UTF8.

Если вы хотите, чтобы все таблицы, которые вы создаете, использовали семантику длины символа по умолчанию, вы можете установить для параметра инициализации NLS_LENGTH_SEMANTICS значение CHAR. В этот момент любые создаваемые вами таблицы будут по умолчанию использовать семантику длины символа, а не семантику длины байта, если вы не укажете CHAR или BYTE в длине поля.

1 голос
/ 23 октября 2016

Это еще одно решение, основанное на бинарном поиске:

public string LimitToUTF8ByteLength(string text, int size)
{
    if (size <= 0)
    {
        return string.Empty;
    }

    int maxLength = text.Length;
    int minLength = 0;
    int length = maxLength;

    while (maxLength >= minLength)
    {
        length = (maxLength + minLength) / 2;
        int byteLength = Encoding.UTF8.GetByteCount(text.Substring(0, length));

        if (byteLength > size)
        {
            maxLength = length - 1;
        }
        else if (byteLength < size)
        {
            minLength = length + 1;
        }
        else
        {
            return text.Substring(0, length); 
        }
    }

    // Round down the result
    string result = text.Substring(0, length);
    if (size >= Encoding.UTF8.GetByteCount(result))
    {
        return result;
    }
    else
    {
        return text.Substring(0, length - 1);
    }
}
1 голос
/ 06 октября 2016

После Комментарий Орен Трутнер Вот еще два решения проблемы:
здесь мы подсчитываем количество байтов, которые нужно удалить из конца строки, в соответствии с каждым символом в конце строки, поэтому мы не оцениваем всю строку в каждой итерации.

string str = "朣楢琴执执 瑩浻牡楧硰执执獧浻牡楧敬瑦 瀰 絸朣杢执獧扻捡杫潲湵 潣" 
int maxBytesLength = 30;
var bytesArr = Encoding.UTF8.GetBytes(str);
int bytesToRemove = 0;
int lastIndexInString = str.Length -1;
while(bytesArr.Length - bytesToRemove > maxBytesLength)
{
   bytesToRemove += Encoding.UTF8.GetByteCount(new char[] {str[lastIndexInString]} );
   --lastIndexInString;
}
string trimmedString = Encoding.UTF8.GetString(bytesArr,0,bytesArr.Length - bytesToRemove);
//Encoding.UTF8.GetByteCount(trimmedString);//get the actual length, will be <= 朣楢琴执执 瑩浻牡楧硰执执獧浻牡楧敬瑦 瀰 絸朣杢执獧扻捡杫潲湵 潣潬昣昸昸慢正 

И еще более эффективное (и обслуживаемое) решение: получить строку из массива байтов в соответствии с желаемой длиной и вырезать последний символ, потому что он может быть поврежден

string str = "朣楢琴执执 瑩浻牡楧硰执执獧浻牡楧敬瑦 瀰 絸朣杢执獧扻捡杫潲湵 潣" 
int maxBytesLength = 30;    
string trimmedWithDirtyLastChar = Encoding.UTF8.GetString(Encoding.UTF8.GetBytes(str),0,maxBytesLength);
string trimmedString = trimmedWithDirtyLastChar.Substring(0,trimmedWithDirtyLastChar.Length - 1);

Единственный недостаток второго решения заключается в том, что мы могли бы вырезать совершенно мелкий последний символ, но мы уже обрезаем строку, поэтому она может соответствовать требованиям.
Спасибо Shhade , который подумал о втором решении

0 голосов
/ 04 марта 2019

Все остальные ответы, похоже, не учитывают тот факт, что эта функциональность уже встроена в .NET в классе Encoder. Для бонусных баллов этот подход также будет работать для других кодировок.

public static String LimitByteLength(string input, int maxLength)
{
    if (string.IsNullOrEmpty(input) || Encoding.UTF8.GetByteLength(input) <= maxLength)
    {
        return message;
    }

    var encoder = Encoding.UTF8.GetEncoder();
    byte[] buffer = new byte[maxLength];
    char[] messageChars = message.ToCharArray();
    encoder.Convert(
        chars: messageChars,
        charIndex: 0,
        charCount: messageChars.Length,
        bytes: buffer,
        byteIndex: 0,
        byteCount: buffer.Length,
        flush: false,
        charsUsed: out int charsUsed,
        bytesUsed: out int bytesUsed,
        completed: out bool completed);

    // I don't think we can return message.Substring(0, charsUsed)
    // as that's the number of UTF-16 chars, not the number of codepoints
    // (think about surrogate pairs). Therefore I think we need to
    // actually convert bytes back into a new string
    return Encoding.UTF8.GetString(bytes, 0, bytesUsed);
}
0 голосов
/ 03 сентября 2015
public static string LimitByteLength3(string input, Int32 maxLenth)
    {
        string result = input;

        int byteCount = Encoding.UTF8.GetByteCount(input);
        if (byteCount > maxLenth)
        {
            var byteArray = Encoding.UTF8.GetBytes(input);
            result = Encoding.UTF8.GetString(byteArray, 0, maxLenth);
        }

        return result;
    }
...