C# – Taglia una stringa UTF-8 al numero di byte specificato

C# – Taglia una stringa UTF-8 al numero di byte specificato

Ecco il modo più semplice per tagliare in modo efficiente una stringa UTF-8 al numero di byte specificato:

public static string TrimToByteLength(this string input, int byteLength)
{
	if (string.IsNullOrEmpty(input))
		return input;
	
	var currentBytes = Encoding.UTF8.GetByteCount(input);
	if (currentBytes <= byteLength)
		return input;

	//Are we dealing with all 1-byte chars? Use substring(). This cuts the time in half.
	if (currentBytes == input.Length)
		return input.Substring(0, byteLength);

	var bytesArray = Encoding.UTF8.GetBytes(input);
	Array.Resize(ref bytesArray, byteLength);
	var wordTrimmed = Encoding.UTF8.GetString(bytesArray, 0, byteLength);

	//If a multi-byte sequence was cut apart at the end, the decoder will put a replacement character '�'
	//so trim off the potential trailing '�'
	return wordTrimmed.TrimEnd('�');
}
Code language: C# (cs)

Una stringa UTF-8 può avere una combinazione di caratteri compresa tra 1 e 4 byte. Quando prendi solo una parte dell'array di byte, potresti finire per tagliare a metà i caratteri multibyte, che poi vengono sostituiti con il carattere sostitutivo ( '�' ) durante la decodifica. Questo è il motivo per cui sta eliminando il carattere sostitutivo finale.

Esistono altri approcci, come il looping e il controllo da soli di sequenze multibyte non valide, ma ciò porta a un codice più difficile da capire e non più efficiente (secondo i benchmark con 1 milione di stringhe di caratteri). Inoltre, una delle migliori ottimizzazioni che puoi fare è usare string.Substring() se hai a che fare solo con caratteri a 1 byte. Ciò porta a un aumento della velocità di 2 volte.

In questo articolo, entrerò in maggiori dettagli su come gestire i caratteri multibyte che vengono tagliati a metà. Alla fine, mostrerò tutti gli unit test utilizzati per dimostrare che il metodo TrimToByteLength() funziona.

Gestire un carattere multi-byte che è stato dimezzato

Se devi gestire solo caratteri a 1 byte, tagliare l'array di byte non sarebbe un problema. In effetti, se così fosse, potresti semplicemente usare string.Substring() invece di codificare/decodificare.

Ma i caratteri codificati UTF-8 possono avere tra 1-4 byte. Dato che stai tagliando in base alla lunghezza dei byte, potresti finire per tagliare a metà parte di un carattere multibyte.

Ad esempio, supponiamo che tu abbia la seguente stringa con un carattere giapponese "か". In UTF-8, questo è un carattere multibyte con i seguenti tre byte:

11100011 10000001 10001011Code language: plaintext (plaintext)

Ora supponiamo che lo stai riducendo a soli 2 byte. Ciò lascerebbe i primi due byte:

11100011 10000001

Questa è una sequenza non valida e per impostazione predefinita il decodificatore la sostituirà con il carattere sostitutivo "�".

Qualsiasi codice che sta tentando di tagliare una stringa a una lunghezza di byte specificata deve affrontare questo problema. Puoi provare a rilevare tu stesso la sequenza multi-byte non valida invertendo l'array di byte ed esaminando i byte, oppure puoi lasciare che il decodificatore faccia il lavoro per te e semplicemente rimuovere il carattere di sostituzione alla fine. Il codice mostrato in questo articolo sta facendo quest'ultimo approccio, perché è molto più semplice non reinventare la ruota.

Come viene rilevata la sequenza multibyte non valida?

UTF-8 è stato progettato per essere in grado di determinare a quale carattere appartiene un byte utilizzando il seguente schema:

Il 1° byte inizia con 2° byte inizia con 3° byte inizia con 4° byte inizia con
Carattere da 1 byte 0
Carattere a 2 byte 110 10 10
Carattere a 3 byte 1110 10 10
Carattere a 4 byte 11110 10 10 10

Il primo byte della sequenza indica che tipo di sequenza è, che indica quanti byte di continuazione cercare. I byte di continuazione iniziano con 10 .

Torniamo all'array di byte con il carattere giapponese “か”:

11100011 10000001 10001011

Quando questo viene ridotto a 2 byte:

11100011 10000001

Quando il decoder esegue questa operazione, vede che il primo byte della sequenza inizia con 111, il che significa che ha a che fare con una sequenza di 3 byte. Si aspetta che i due byte successivi siano byte di continuazione (byte che iniziano con 10 ), ma vede solo un byte di continuazione (10 000001). Quindi questa è una sequenza di byte non valida e viene sostituita con il carattere sostitutivo '�'.

Altri esempi di caratteri e loro sequenze di byte UTF-8

Ecco altri esempi di caratteri e le loro sequenze di byte.

Personaggio Unicode Sequenza di byte
a U+0061 0 1100001
Ć U+0106 11 000100 10 000110
ꦀ (carattere giavanese) U+A980 111 01010 10 100110 10 000000
𒀃 (carattere cuneiforme sumero) U+12003 1111 0000 10 010010 10 000000 10 000011

Notare il modello nelle sequenze di byte. I primi 4 bit del primo byte indicano che vuoi che tipo di sequenza sia, seguiti dai byte di continuazione (che iniziano tutti con 10 ).

Prove unitarie

Il metodo TrimToByteLength() è stato testato utilizzando i seguenti unit test con parametri. Questo esercita ogni scenario, inclusa la verifica di cosa succede quando le sequenze multi-byte vengono separate.

[TestClass()]
public class TrimToByteLengthTests
{
	[DataRow(null)]
	[DataRow("")]
	[TestMethod()]
	public void WhenEmptyOrNull_ReturnsAsIs(string input)
	{
		//act
		var actual = input.TrimToByteLength(10);

		//assert
		Assert.AreEqual(input, actual);
	}
	[DataRow("a")] //1 byte
	[DataRow("Ć")] //2 bytes
	[DataRow("ꦀ")] //3 bytes - Javanese
	[DataRow("𒀃")] //4 bytes - Sumerian cuneiform
	[DataRow("a𒀃")] //5 bytes
	[TestMethod()]
	public void WhenSufficientLengthAlready_ReturnsAsIs(string input)
	{
		//act
		var actual = input.TrimToByteLength(byteLength: 5);

		//assert
		Assert.AreEqual(input, actual);
	}
	[DataRow("abc", 1, "a")] //3 bytes, want 1
	[DataRow("abĆ", 2, "ab")] //4 bytes, want 2
	[DataRow("aꦀ", 1, "a")] //4 bytes, want 1
	[DataRow("a𒀃c", 5, "a𒀃")] //6 bytes, want 5
	[DataRow("aĆ𒀃", 3, "aĆ")] //7 bytes, want 3
	[TestMethod()]
	public void WhenStringHasTooManyBytes_ReturnsTrimmedString(string input, int byteLength, string expectedTrimmedString)
	{
		//act
		var actual = input.TrimToByteLength(byteLength);

		//assert
		Assert.AreEqual(expectedTrimmedString, actual);
	}
	[DataRow("Ć", 1, "")] //2 byte char, cut in half
	[DataRow("ꦀ", 2, "")] //3 byte char, cut at 3rd byte
	[DataRow("ꦀ", 1, "")] //3 byte char, cut at 2nd byte
	[DataRow("𒀃", 3, "")] //4 byte char, cut at 4th byte
	[DataRow("𒀃", 2, "")] //4 byte char, cut at 3rd byte
	[DataRow("𒀃", 1, "")] //4 byte char, cut at 2nd byte
	[DataRow("a𒀃", 2, "a")] //1 byte + 4 byte char. Multi-byte cut in half
	[TestMethod()]
	public void WhenMultiByteCharSequenceIsCutInHalf_ItAndReplacementCharAreTrimmedOut(string input, int byteLength, string expectedTrimmedString)
	{
		//act
		var actual = input.TrimToByteLength(byteLength);

		//assert
		Assert.AreEqual(expectedTrimmedString, actual);
	}
}
Code language: C# (cs)