Artigo Técnico

Emojis e Caracteres CJK Corrompem o WideChar no PDFium em Delphi

Extraia um emoji ou um nome de registo familiar japonês de um PDF como texto, e a saída mostra uma caixa, um ponto de interrogação, ou nada de todo onde o carácter deveria estar. A propriedade Character[] do PDFium Component é normalmente a razão: lê cada glifo através de FPDFText_GetUnicode, que devolve um ponto de código Unicode completo como um valor sem sinal de 32 bits, e depois expõe-o ao Delphi como um único WideChar de 16 bits. Qualquer ponto de código além de U+FFFF não consegue fazer essa viagem de uma só vez, e a corrupção nunca se manifesta enquanto se está a olhar para a página renderizada, porque a renderização e a extração de texto percorrem caminhos de código separados dentro do PDFium — um documento pode apresentar os seus emojis na perfeição e, ainda assim, entregar dados corrompidos no momento em que se lê Character[] num ciclo e se constrói uma cadeia de texto a partir daí

O Plano Multilingue Básico e porque é que o WideChar para em U+FFFF

O WideChar do Delphi é um tipo de 16 bits que só consegue guardar uma unidade de código UTF-16. O Plano Multilingue Básico do Unicode, o intervalo U+0000 a U+FFFF, cabe exatamente aí, razão pela qual o Latim, o Cirílico, o Grego, e o bloco comum de Ideogramas Unificados CJK fazem todos a viagem de ida e volta através de um único WideChar sem incidentes. Duas famílias de caracteres caem rotineiramente fora desse limite em documentos reais: os emojis, muitos deles no bloco Emoticons a começar em U+1F600, e ideogramas CJK raros da Extensão B de Ideogramas Unificados CJK, o intervalo U+20000 a U+2A6DF reservado para caracteres chineses, japoneses, e coreanos menos comuns, incluindo muitos nomes pessoais e de lugares. O UTF-16 trata qualquer valor acima de U+FFFF com um par substituto (surrogate pair) — duas unidades de código de 16 bits, um substituto alto no intervalo $D800 a $DBFF seguido de um substituto baixo em $DC00 a $DFFF, que juntos codificam um único ponto de código — e a matemática por trás desse emparelhamento é suficientemente fixa para se demonstrar diretamente em Pascal

function ToSurrogatePair(CodePoint: LongWord; out Hi, Lo: WideChar): Boolean;
var
  V: LongWord;
begin
  Result := CodePoint > $FFFF;
  if Result then
  begin
    V := CodePoint - $10000;
    Hi := WideChar($D800 + (V shr 10));
    Lo := WideChar($DC00 + (V and $3FF));
  end;
end;

Ao passar U+1F600, o emoji de cara sorridente, por esta função, o resultado é um substituto alto de $D83D e um substituto baixo de $DE00, dois valores de 16 bits, não um. Nenhuma das metades significa nada por si só; um $D83D solitário numa cadeia de texto sem o $DE00 correspondente atrás dele é um substituto pendente (dangling surrogate), e a maior parte do código de tratamento de texto que se depara com um destes ou o descarta, ou o substitui por um glifo de substituição, ou levanta um erro

Porque é que o FPDFText_GetUnicode devolve um valor que o Character[] não consegue guardar?

O FPDFText_GetUnicode devolve um LongWord, um valor completo de 32 bits, porque a codificação de texto de um PDF já transporta o valor escalar Unicode completo para cada glifo. O CMap ToUnicode de um PDF mapeia códigos de carácter para texto Unicode, e quando um glifo representa aquilo a que informalmente se chama um carácter do plano astral — qualquer coisa além do Plano Multilingue Básico — esse mapeamento é um ponto de código completo, não um fragmento de 16 bits. O PDFium descodifica-o internamente de volta para um valor escalar e devolve-o através da fronteira da DLL através de FPDFText_GetUnicode, e é exatamente nessa fronteira que um valor de 32 bits tem de se tornar algo que uma propriedade Delphi consegue devolver ao código do utilizador

A implementação óbvia é WideChar(FPDFText_GetUnicode(TextPage, Index)), e é também a errada. Uma conversão direta (hard cast) de um valor de 32 bits para um tipo de 16 bits mantém apenas os 16 bits menos significativos e descarta o resto silenciosamente, sem qualquer exceção e sem qualquer verificação de intervalo. Para U+1F600 isso significa manter $F600 e perder o facto de que o valor verdadeiro alguma vez esteve acima de U+FFFF, o que produz uma unidade de código que nem sequer é um substituto pendente válido, apenas um carácter não relacionado do Plano Multilingue Básico que calha partilhar esses bits mais baixos. Concatene alguns milhares destes numa cadeia de texto e o código a jusante já não tem forma de distinguir um carácter corrompido de um legítimo

O que devolvem agora Character[] e Charcode[] para pontos de código do plano astral

As propriedades Character[] e Charcode[] do PDFium Component devolvem U+FFFD, o carácter de substituição Unicode, sempre que o ponto de código subjacente exceda U+FFFF, em vez de o truncar silenciosamente. Essa proteção está diretamente dentro do getter da propriedade por trás de Character[]

function TPdf.GetCharacter(Index: Integer): WideChar;
var
  Code: LongWord;
begin
  LoadTextPage;
  Code := FPDFText_GetUnicode(FTextPage, Index);
  if Code > $FFFF then
    Result := #$FFFD          // astral-plane code point: cannot fit in one WideChar
  else
    Result := WideChar(Code);
end;

Devolver U+FFFD em vez de um fragmento truncado é uma correção deliberada e restrita, não uma reconceção. Character[] e Charcode[] estão tipificadas como WideChar tanto em TPdf como em TPdfView, e alargar esse tipo de retorno para transportar um ponto de código completo quebraria todo o código chamador existente que espera que um glifo por índice signifique um único valor de 16 bits. O U+FFFD é o próprio marcador de posição designado pelo Padrão Unicode exatamente para esta situação, pelo que um chamador que verifique a sua presença recebe um sinal definido e documentado em vez de dados silenciosamente errados. Vale a pena conhecer um caso limite: o U+FFFD também é, por direito próprio, um carácter legítimo, pelo que, no raro documento que já contenha um glifo de carácter de substituição genuíno, esse índice é indistinguível de um carácter astral truncado apenas pelo valor

Como extrair corretamente emojis e texto da Extensão B de CJK em Delphi?

Chame Text em vez de percorrer Character[] sempre que o conteúdo textual real importar, porque o Text lê através de FPDFText_GetText e devolve uma WString completa com pares substitutos corretos para cada carácter do plano astral dentro do intervalo, em vez de um valor de largura fixa por índice. O Pdf.Text(0, MaxInt), ou a forma abreviada Pdf.Text, extrai uma página inteira corretamente numa única chamada, e o Pdf.Text(StartIndex, Count) extrai um intervalo mais pequeno da mesma forma. O Character[] continua a justificar o seu lugar quando só se precisa de dados de posição, tipo de letra, ou de sinalizadores num índice e nunca se toca no próprio ponto de código — CharacterOrigin[], FontSize[], e CharacterMapError[] não se importam se o glifo subjacente era astral

function ExtractLineSafely(Pdf: TPdf): WString;
var
  I: Integer;
begin
  Result := '';
  for I := 0 to Pdf.CharacterCount - 1 do
    if not (Pdf.CharacterGenerated[I] or Pdf.CharacterMapError[I]) then
      Result := Result + Pdf.Text(I, 1);   // full code point, never a truncated WideChar
end;

A verificação de "ignorar gerado e não mapeado" nesse ciclo é o mesmo padrão usado na extração de texto simples em extrair texto de documentos PDF com o PDFium Component; a única alteração é a última linha, que troca um acréscimo direto de Character[I] por uma chamada de um índice a Text, de modo que os caracteres astrais cheguem como pares substitutos completos em vez de marcadores de substituição

Onde isto realmente causa problemas: exportações de chat, nomes pessoais, e tipos de letra CJK incorporados

Os emojis aparecem em qualquer lugar onde um PDF capture comunicação informal: registos de chat exportados, extratos de avaliações de lojas de aplicações, transcrições de sistemas de tickets guardadas em PDF para um arquivo de conformidade. A Extensão B de CJK aparece num contexto mais restrito mas de risco mais elevado, nomes pessoais e de lugares, porque os registos familiares japoneses, os registos de agregado familiar chineses, e os documentos de identidade taiwaneses são fontes clássicas de caracteres que nunca chegaram a integrar o bloco comum de CJK. Um pipeline de processamento de salários ou de verificação de identidade que extraia nomes de documentação governamental digitalizada é exatamente o tipo de carga de trabalho onde um carácter silenciosamente corrompido se transforma numa correspondência falhada, em vez de uma falha meramente estética

Os ideogramas CJK raros também costumam vir acompanhados de problemas de tipo de letra, não apenas de codificação, porque um tipo de letra tem de conter um glifo para um ponto de código no intervalo U+20000 antes de sequer se conseguir renderizar seja o que for, e poucos tipos de letra de sistema instalados o fazem. Quem já percorra FontIsEmbedded[] por carácter da forma descrita em ler propriedades de tipo de letra de PDF com o PDFium Component deve verificar o mesmo índice para ambos os problemas em conjunto: um índice que devolve U+FFFD a partir de Character[] e reporta um tipo de letra não incorporado é um documento que não vai nem extrair nem imprimir corretamente esse carácter, e a correção pertence a montante, na forma como o PDF foi produzido, não no código de extração

As propriedades Character[], Charcode[], e Text aqui descritas fazem parte do PDFium Component padrão para Delphi e C++Builder