Transforme a string UTF8 em UCS-2 com a substituição de caracteres inválidos em java

Nov 17 2020

Eu tenho uma picada em UTF8:

"Red🌹🌹Röses"

Eu preciso que isso seja convertido para codificação UCS-2 válida (ou UTF-16BE de tamanho fixo sem BOM, eles são as mesmas coisas), então a saída será: "Red Röses" como o "🌹" fora do intervalo de UCS- 2

O que eu tentei:

 @Test
public void testEncodeProblem() throws CharacterCodingException {
    String in = "Red\uD83C\uDF39\uD83C\uDF39Röses";
    ByteBuffer input = ByteBuffer.wrap(in.getBytes());

    CharsetDecoder utf8Decoder = StandardCharsets.UTF_16BE.newDecoder();
    utf8Decoder.onMalformedInput(CodingErrorAction.REPLACE);
    utf8Decoder.onUnmappableCharacter(CodingErrorAction.REPLACE);
    utf8Decoder.replaceWith(" ");

    CharBuffer decoded = utf8Decoder.decode(input);

    System.out.println(decoded.toString()); //  剥擰龌맰龌륒쎶獥 
}

Não.

    @Test
public void testEncodeProblem() {
    String in = "Red\uD83C\uDF39\uD83C\uDF39Röses";
    byte[] bytes = in.getBytes(StandardCharsets.UTF_16BE);
    String res = new String(bytes);
    System.out.println(res); //  Red�<�9�<�9Röses
}

Não.

Observe que "ö" é um símbolo UCS-2 válido.

Alguma ideia / biblioteca?

Respostas

3 rzwitserloot Nov 17 2020 at 01:13

Infelizmente, ambos os trechos não funcionam, e isso é porque você não entendeu a codificação UTF-16. UTF-16 PODE codificar esses emojis, NÃO tem largura fixa. Não existe algo como 'corrigido com codificação UTF-16'. Há .. UCS2. Que não é UTF-16. A parte BE não a torna "largura fixa", ela apenas bloqueia o endianismo. É por isso que ambos imprimem as rosas. O Java infelizmente não vem com um sistema de codificação UCS2, o que torna esse trabalho mais difícil e feio.

Além disso, ambos os snippets falham porque você está chamando métodos proibidos.

Sempre que você converte bytes em caracteres ou vice-versa, a conversão de caracteres ESTÁ acontecendo . Você não pode optar por sair disso. No entanto, existem vários métodos que não precisam de nenhum parâmetro para indicar qual codificação de conjunto de caracteres você gostaria de usar para isso. Estes são os métodos proibidos: o padrão é 'padrão do sistema' e parece que de alguma forma alguém acenou com uma varinha mágica e fez com que pudéssemos converter caracteres em bytes ou vice-versa sem nos preocupar com a codificação de caracteres.

A solução é nunca usar os métodos proibidos. Melhor ainda, diga ao seu IDE que ele deve sinalizá-los como erros. As únicas exceções são quando você SABE que os padrões da API não são 'padrão da plataforma', mas algo lógico - a única que eu conheço é a Files.*API, que tem como padrão UTF-8 e não o padrão da plataforma. Portanto, usar as variantes sem charset é aceitável.

Se você realmente deve ter o padrão da plataforma (sensível apenas para ferramentas de linha de comando), torne-o explícito passando Charset.defaultCharset().

A lista de métodos proibidos é bastante longa, mas new String(bytes)e string.getBytes()estão nela. Não use esses métodos / construtores. Nunca .

Além disso, o seu primeiro fragmento é totalmente confuso. Você deseja ENCODE uma string (uma string já contém caracteres e não tem codificação. É o que é. Então, por que você está fazendo um decodificador quando não há nada para decodificar?) Para UTF-16, não decodificá-lo:

String in = "Red\uD83C\uDF39\uD83C\uDF39Röses";
CharBuffer input = CharBuffer.wrap(in);
CharsetEncoder utf16Encoder = StandardCharsets.UTF_16BE.newEncoder();
utf16Encoder.onUnmappableCharacter(CodingErrorAction.REPLACE);
utf16Encoder.replaceWith(" ");
ByteBuffer encoded = utf16Encoder.encode(input);

System.out.println(new String(encoded.array(), StandardCharsets.UTF16_BE));

ou segundo snippet:

@Test
public void testEncodeProblem() {
    String in = "Red\uD83C\uDF39\uD83C\uDF39Röses";
    byte[] bytes = in.getBytes(StandardCharsets.UTF_16BE);
    String res = new String(bytes, StandardCharsets.UTF_16BE);
    System.out.println(res);
}

Mas, como falei, ambos imprimem apenas as rosas, pois essas são representáveis ​​em UTF_16.

Então, como fazer o trabalho? Se o java tivesse uma codificação UCS2 embutida, seria simples substituí StandardCharsets.UTF_16BE-lo StandardCharsets.UCS2, mas não teve sorte. Então, eu acho ... provavelmente 'à mão':

String in = "Red\uD83C\uDF39\uD83C\uDF39Röses";
ByteArrayOutputStream out = new ByteArrayOutputStream();
in.codePoints()
    .filter(a -> a < 65536)
    .forEach(a -> {
       out.write(a >> 8);
       out.write(a);
    });

// stream is ugly, but, because codePoints() was added in a time
// when oracle had just invented the shiny hammer, they are using it
// here for smearing butter on their sandwich. Silly geese. Oh well.

byte[] result = out.toByteArray();
// given that java has no way of reading UCS2, and UTF16BE doesn't fit,
// as there are chars representable in 2 bytes in UCS2 that take 3+ in
// UTF16BE, it's not possible to print this without another loop similar to above. 
// Let's just print the bytes and check em, by hand:

for (byte r : result) System.out.print(" " + (r & 0xFF));
System.out.println();
// For the roses string, printing with UTF-16BE does actually work,
// but it won't be true for all input strings...
System.out.println(new String(result, StandardCharsets.UTF_16BE));

yay! Sucesso!

NB: codePointAtpoderia funcionar e evitar o fluxo feio aqui, mas a entrada do cPA não está em 'codepoint index', mas em 'char index' e isso torna as coisas um tanto complicadas; você teria que aumentar em 2 para qualquer par substituto.


Alguma introspecção em unicode, UCS2 e UTF-16:

Unicode é uma tabela gigantesca que mapeia qualquer número entre 0 e 1.112.064 (que é cerca de 20 bits e meio) para um caractere, conceito de controle, moeda, pontuação, emoji, desenho de caixa ou outro conceito característico.

Uma codificação como UTF-8 ou US_ASCII define uma tradução para alguns ou todos esses números em uma série de bytes, de modo que também pode ser decodificado de volta para uma sequência de pontos de código, que são comumente armazenados em 32 bits, porque eles não cabe no 16, e nenhuma arquitetura por aí trata de forma significativa, por exemplo, 24 bits ou qualquer outra coisa.

Para acomodar UCS2 / UTF-16, NÃO há caracteres na especificação Unicode de 0xD800 a 0xDFFF, e isso é intencional e nunca haverá.

Isso significa que UCS2 e UTF-16 são mais ou menos a mesma coisa, com um 'truque':

Para qualquer número Unicode que esteja abaixo de 65536 (então poderia caber teoricamente em 2 bytes), para codificação UTF-16 (que PODE codificar emoji e outros), a codificação UTF-16 é apenas .. o número. direto para cima. Como 2 bytes. A D800-DFFF não pode acontecer, porque esses pontos de código intencionalmente não são uma coisa.

Para qualquer coisa acima de 65536, esse bloco livre de D800 a DFFF é usado para produzir o chamado par substituto. Um segundo 'caractere' (um segundo bloco de 2 bytes) se combina com os 11 bits de dados que podemos armazenar com nosso intervalo D800-DFFF para um total de 16 + 11 = 27 bits, mais do que o suficiente para cobrir o resto.

Portanto, o UTF-16 codificará qualquer ponto de código Unicode como 2 bytes ou 4 bytes.

UCS-2 como um termo quase perdeu seu significado. Originalmente, significava exatamente 2 bytes por "caractere", nem mais nem menos, e ainda significa isso, mas o significado de "um caractere" foi distorcido e irreconhecível: Aquela rosa? Conta como 2 caracteres. Experimente em java - x.length()retorna 2, não 1. Uma definição um tanto sensata de UCS-2 como: 1 char realmente significa 1 char, cada char é representado por 2 bytes, e se você tentar armazenar um char que não se encaixa ( seria um par substituto), bem, aqueles simplesmente não podem ser codificados, então bloqueie ou aplique o marcador de posição on-unreprestable-character. Infelizmente, não é (sempre) o que UCS-2 significa, o que nos leva de volta a ter que escrever qualquer código que aplique esta operação (descartar / substituir-com-espaço reservado quaisquer pares substitutos para que o comprimento em bytes seja exatamente 2 * número de pontos de código).

Observe que esse par substituto fornece uma estratégia diferente, com base no fato de que o java charestá muito próximo dos ideais do UCS2 (no sentido de que é um número de 16 bits, codificado na especificação do java): Você pode apenas fazer um loop através todos os caracteres (como em, java's char) e descarte qualquer coisa assim c >= 0xD800 && c < 0xE000, bem como o caractere imediatamente seguinte , que vai se livrar das rosas.