Conversão de caracteres desnormalizados com UTF8String

Aug 25 2020

Ao converter o emoji codificado em UTF-8 em string, não obtivemos os caracteres corretos usando UTF8ToString. Recebemos esses caracteres UTF8 de uma interface externa. Testamos os caracteres UTF com um decodificador UTF8 online e vimos que eles contêm os caracteres corretos. Suspeito que sejam personagens compostos.

procedure TestUTF8Convertion;
const
  utf8Denormalized: RawByteString = #$ED#$A0#$BD#$ED#$B8#$85#$20 + #$ED#$A0#$BD#$ED#$B8#$86#$20 + #$ED#$A0#$BD#$ED#$B8#$8A;
  utf8Normalized: RawByteString = #$F0#$9F#$98#$85 + #$F0#$9F#$98#$86 + #$F0#$9F#$98#$8A;
begin
  Memo1.Lines.Add(UTF8ToString(utf8Denormalized));
  Memo1.Lines.Add(UTF8ToString(utf8Normalized));
end;

Saída em Memo1:

Desnormalizado:

Normalizado: 😅😆😊

Escrever a própria função de conversão baseada na função WinApi MultiByteToWideCharnão resolveu esse problema.

function UTF8DenormalizedToString(s: PAnsiChar): string;
var
  pwc: PWideChar;
  len: cardinal;
begin
  GetMem(pwc, (Length(s) + 1) * SizeOf(WideChar));
  len := MultiByteToWideChar(CP_UTF8, MB_PRECOMPOSED, @s[0], -1, pwc, length(s));
  SetString(result, pwc, len);
  FreeMem(pwc);
end;

Respostas

2 SalvadorDíazFau Aug 26 2020 at 23:09

Se você tiver dados CESU-8 em um buffer e precisar convertê-los em UTF-8, poderá substituir os pares substitutos por um único caractere codificado em UTF-8. O resto dos dados podem ser deixados inalterados.

Nesse caso, seu emoji é este:

  • ponto de código: 01 F6 05
  • UTF-8: F0 9F 98 85
  • UTF-16: D8 3D DE 05
  • CESU-8: ED A0 BD ED B8 85

O substituto alto em CESU-8 tem estes dados: $ 003D

E o substituto baixo no CESU-8 tem estes dados: $ 0205

Como Remy e AmigoJack apontaram, você encontrará esses valores quando decodificar a versão UTF-16 do emoji.

No caso do UTF-16, você também precisará multiplicar o $003D value by $400 (shl 10), adicione o resultado a $0205 and then add $10000 até o resultado final para obter o ponto de código.

Depois de ter o ponto de código, você pode convertê-lo em um conjunto de valores UTF-8 de 4 bytes.

function ValidHighSurrogate(const aBuffer: array of AnsiChar; i: integer): boolean;
var
  n: byte;
begin
  Result := False;
  if (ord(aBuffer[i]) <> $ED) then exit; n := ord(aBuffer[i + 1]) shr 4; if ((n and $A) <> $A) then exit; n := ord(aBuffer[i + 2]) shr 6; if ((n and $2) = $2) then Result := True; end; function ValidLowSurrogate(const aBuffer: array of AnsiChar; i: integer): boolean; var n: byte; begin Result := False; if (ord(aBuffer[i]) <> $ED) then
    exit;

  n := ord(aBuffer[i + 1]) shr 4;
  if ((n and $B) <> $B) then
    exit;

  n := ord(aBuffer[i + 2]) shr 6;
  if ((n and $2) = $2) then
    Result := True;
end;

function GetRawSurrogateValue(const aBuffer: array of AnsiChar; i: integer): integer;
var
  a, b: integer;
begin
  a := ord(aBuffer[i + 1]) and $0F; b := ord(aBuffer[i + 2]) and $3F;

  Result := (a shl 6) or b;
end;

function CESU8ToUTF8(const aBuffer: array of AnsiChar): boolean;
var
  TempBuffer: array of AnsiChar;
  i, j, TempLen: integer;
  TempHigh, TempLow, TempCodePoint: integer;
begin
  TempLen := length(aBuffer);
  SetLength(TempBuffer, TempLen);

  i := 0;
  j := 0;
  while (i < TempLen) do
    if (i + 5 < TempLen) and ValidHighSurrogate(aBuffer, i) and
      ValidLowSurrogate(aBuffer, i + 3) then
    begin
      TempHigh := GetRawSurrogateValue(aBuffer, i);
      TempLow := GetRawSurrogateValue(aBuffer, i + 3);
      TempCodePoint := (TempHigh shl 10) + TempLow + $10000; TempBuffer[j] := AnsiChar($F0 + ((TempCodePoint and $1C0000) shr 18)); TempBuffer[j + 1] := AnsiChar($80 + ((TempCodePoint and $3F000) shr 12)); TempBuffer[j + 2] := AnsiChar($80 + ((TempCodePoint and $FC0) shr 6)); TempBuffer[j + 3] := AnsiChar($80 + (TempCodePoint and $3F));
      inc(j, 4);
      inc(i, 6);
    end
    else
    begin
      TempBuffer[j] := aBuffer[i];
      inc(i);
      inc(j);
    end;

  Result := < save the buffer here >;
end;
2 AmigoJack Aug 25 2020 at 23:27
  • UTF-8 consiste em 1, 2, 3 ou 4 bytes por caractere. O codepoint U + 1F605 está codificado corretamente como .#$F0#$9F#$98#$85
  • UTF-16 consiste em 2 ou 4 bytes por caractere. As sequências de 4 bytes são necessárias para codificar pontos de código além de U + FFFF (como a maioria dos Emojis). Apenas UCS-2 é limitado a pontos de código U + 0000 a U + FFFF (isso se aplica a versões do Windows NT anteriores a 2000).
  • Uma sequência como (UTF-8 substituto alto, seguido por substituto baixo) não é UTF-8 válido, mas sim CESU-8 - resulta de ingênuo, portanto tradução imprópria de UTF-16 para UTF-8: em vez de (reconhecendo e ) traduzindo uma sequência UTF-16 de 4 bytes (codificando um ponto de código) em uma sequência UTF-8 de 4 bytes apenas e sempre 2 bytes são traduzidos, transformando 2x2 bytes em uma sequência UTF-8 de 6 bytes inválida.#$ED#$A0#$BD#$ED#$B8#$85

Converter sua sequência UTF-8 válida em uma sequência UTF-16 válida funciona para mim. Claro, certifique-se de usar uma fonte adequada que seja realmente capaz de renderizar Emojis:#$F0#$9F#$98#$85#$3d#$d8#$05#$de

// const CP_UTF8= 65001;

function Utf8ToUtf16( const sIn: AnsiString; iSrcCodePage: DWord= CP_UTF8 ): WideString;
var
  iLenDest, iLenSrc: Integer;
begin
  // First calculate how much space is needed
  iLenSrc:= Length( sIn );
  iLenDest:= MultiByteToWideChar( iSrcCodePage, 0, PAnsiChar(sIn), iLenSrc, nil, 0 );

  // Now provide the accurate space
  SetLength( result, iLenDest );
  if iLenDest> 0 then begin  // Otherwise ERROR_INVALID_PARAMETER might occur
    if MultiByteToWideChar( iSrcCodePage, 0, PAnsiChar(sIn), iLenSrc, PWideChar(result), iLenDest )= 0 then begin
      // GetLastError();
      result:= '';
    end;
  end;
end;

...
  Edit1.Font.Name:= 'Segoe UI Symbol';  // Already available in Win7
  Edit1.Text:= Utf8ToUtf16( AnsiString(#$F0#$9F#$98#$85' vs. '#$ED#$A0#$BD#$ED#$B8#$85) );
  // Should display: 😅 vs. ����

Que eu saiba, o Windows não tem uma página de códigos para CESU-8, nem para WTF-8 e, como tal, não lida com seu UTF-8 inválido. Além disso, o uso de MB_PRECOMPOSEDé desencorajado e não se aplica a este caso.

Fale com quem lhe der UTF-8 inválido e exija que o trabalho dele seja corrigido (ou lhe entregue o UTF-16 imediatamente). Caso contrário, você deve pré-processar o UTF-8 de entrada examinando-o em busca de pares substitutos correspondentes para, então, substituir esses bytes em uma sequência apropriada. Não impossível, nem tão difícil, mas um trabalho enfadonho de paciência.

2 RemyLebeau Aug 25 2020 at 23:25

#$ED#$A0#$BDé a forma codificada em UTF-8 de ponto de código Unicode U+D83D, que é um substituto alto .

#$ED#$B8#$85é a forma codificada em UTF-8 de ponto de código Unicode U+DE05, que é um substituto baixo .

#$F0#$9F#$98#$85é a forma codificada UTF-8 de ponto de código Unicode U+1F605.

Os pontos de código Unicode no intervalo substituto são reservados para UTF-16 e são ilegais para uso por conta própria, razão pela qual você vê quando impresso.

Esses substitutos são os substitutos UTF-16 adequados para o ponto de código Unicode U + 1F605 ( 😅).

Portanto, o que você tem é um problema de codificação dupla que precisa ser corrigido na fonte onde os dados UTF-8 estão sendo gerados. U+1F605está sendo codificado primeiro para UTF-16, não UTF-8, e então seus substitutos estão sendo maltratados como pontos de código Unicode e codificados individualmente para UTF-8. Em vez disso, o que você deseja é que o codepoint U+1F605seja codificado no estado em que se encontra diretamente para UTF-8.

Se você não conseguir corrigir a origem dos dados UTF-8, terá apenas que detectar manualmente essa codificação malformada e tratar os dados como UTF-16. Decodifique os dados UTF-8 para UTF-32 e, se o resultado contiver quaisquer pontos de código substitutos, crie uma string UTF-16 separada do mesmo comprimento e copie os pontos de código como estão nessa string, truncando seus valores para 16 bits. Então você pode usar essa string UTF-16 conforme necessário. Caso contrário, se nenhum substituto estiver presente, você pode decodificar o UTF-8 diretamente para uma string UTF-16 normalmente e usar esse resultado.

ATUALIZAÇÃO : conforme mencionado na resposta de @AmigoJack, esses dados estão usando a codificação CESU-8 (isso está documentado na interface de origem?). Portanto, sabendo disso agora, você pode simplesmente renunciar à detecção manual e assumir que todos os dados UTF-8 desta fonte são CESU-8 e decodificá-los manualmente como descrevi acima (nem MultiByteToWideChar()nem o Delphi RTL será capaz de manipulá-los automaticamente para você), pelo menos até que a interface seja corrigida, por exemplo:

function UTF8DenormalizedToString(s: PAnsiChar): UnicodeString;
var
  utf32: UCS4String;
  len, i: Integer;
begin
  utf32 := ... decode utf8 to utf32 ...; // I leave this as an exercise for you!
  len := Length(utf32) - 1; // UCS4String includes a null terminator
  SetLength(Result, len);
  for i := 1 to len do
    Result[i] := WideChar(utf32[i-1] and $FFFF); // UCS4String is 0-indexed
end;