Articolo tecnico

Emoji e Caratteri CJK Rompono WideChar in PDFium su Delphi

Estrai emoji o un nome di registro familiare giapponese da un PDF come testo, e l'output mostra un riquadro, un punto interrogativo, o nulla del tutto dove dovrebbe esserci il carattere. La proprietà Character[] del componente PDFium è di solito il motivo: legge ogni glifo tramite FPDFText_GetUnicode, che restituisce un code point Unicode completo come valore senza segno a 32 bit, poi lo espone a Delphi come un singolo WideChar a 16 bit. Qualsiasi code point oltre U+FFFF non può fare quel viaggio in un unico pezzo, e la corruzione non si manifesta mai mentre stai guardando la pagina renderizzata, perché rendering ed estrazione testo passano per percorsi di codice separati in PDFium — un documento può visualizzare le proprie emoji perfettamente e comunque restituirti spazzatura nel momento in cui leggi Character[] in un ciclo e ne costruisci una stringa

Il Basic Multilingual Plane e perché WideChar si ferma a U+FFFF

Il WideChar di Delphi è un tipo a 16 bit che può contenere solo una unità di codice UTF-16. Il Basic Multilingual Plane di Unicode, l'intervallo da U+0000 a U+FFFF, ci sta esattamente dentro, motivo per cui latino, cirillico, greco e il comune blocco CJK Unified Ideographs viaggiano tutti andata e ritorno attraverso un singolo WideChar senza incidenti. Due famiglie di caratteri cadono regolarmente al di fuori in documenti reali: le emoji, molte di esse nel blocco Emoticons a partire da U+1F600, e i rari ideogrammi CJK dal CJK Unified Ideographs Extension B, l'intervallo da U+20000 a U+2A6DF riservato a caratteri cinesi, giapponesi e coreani meno comuni, inclusi molti nomi personali e di luogo. UTF-16 gestisce qualsiasi cosa sopra U+FFFF con una coppia surrogata — due unità di codice a 16 bit, un surrogato alto nell'intervallo $D800-$DBFF seguito da un surrogato basso in $DC00-$DFFF, che insieme codificano un code point — e la matematica dietro quell'accoppiamento è abbastanza fissa da poterla dimostrare direttamente in 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;

Passa U+1F600, l'emoji del viso sorridente, attraverso quella funzione e il risultato è un surrogato alto di $D83D e un surrogato basso di $DE00, due valori a 16 bit, non uno. Nessuna delle due metà significa nulla da sola; un $D83D solitario seduto in una stringa senza $DE00 dietro è un surrogato pendente, e la maggior parte del codice di gestione testo che ne incontra uno o lo scarta, o lo sostituisce con un glifo di rimpiazzo, o solleva un errore

Perché FPDFText_GetUnicode restituisce un valore che Character[] non può contenere?

FPDFText_GetUnicode restituisce un LongWord, un valore completo a 32 bit, perché la codifica testo PDF porta già il valore scalare Unicode completo per ogni glifo. La CMap ToUnicode di un PDF mappa i codici carattere su testo Unicode, e quando un glifo rappresenta ciò che viene informalmente chiamato un carattere astral-plane — qualsiasi cosa oltre il Basic Multilingual Plane — quella mappatura è un code point completo, non un frammento a 16 bit. PDFium lo decodifica internamente in un valore scalare e lo restituisce attraverso il confine della DLL tramite FPDFText_GetUnicode, ed è esattamente a quel confine che un valore a 32 bit deve diventare qualcosa che una proprietà Delphi possa restituire al tuo codice

L'implementazione ovvia è WideChar(FPDFText_GetUnicode(TextPage, Index)), ed è anche quella sbagliata. Un cast diretto da un valore a 32 bit in un tipo a 16 bit mantiene solo i 16 bit bassi e butta via il resto silenziosamente, senza eccezione e senza controllo di intervallo. Per U+1F600 questo significa mantenere $F600 e perdere il fatto che il vero valore fosse mai sopra U+FFFF, il che produce un'unità di codice che non è nemmeno un surrogato pendente valido, solo un carattere del Basic Multilingual Plane non correlato che per caso condivide quei bit bassi. Concatena qualche migliaio di questi in una stringa e il codice a valle non ha più modo di distinguere un carattere corrotto da uno legittimo

Cosa restituiscono ora Character[] e Charcode[] per i code point astral-plane

Le proprietà Character[] e Charcode[] del componente PDFium restituiscono U+FFFD, il carattere di rimpiazzo Unicode, ogni volta che il code point sottostante eccede U+FFFF, invece di troncarlo silenziosamente. Quella protezione risiede direttamente dentro il getter della proprietà dietro 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;

Restituire U+FFFD invece di un frammento troncato è una correzione deliberata e mirata piuttosto che una riprogettazione. Character[] e Charcode[] sono tipizzate come WideChar sia su TPdf sia su TPdfView, e allargare quel tipo di ritorno per portare un code point completo romperebbe ogni chiamante esistente che si aspetta un glifo per indice significhi un valore a 16 bit. U+FFFD è il segnaposto designato dallo Standard Unicode stesso proprio per questa situazione, quindi un chiamante che lo controlla ottiene un segnale definito e documentato invece di dati silenziosamente sbagliati. Un caso limite utile da conoscere: U+FFFD è anche un carattere legittimo per proprio conto, quindi sul raro documento che già contiene un genuino glifo carattere-di-rimpiazzo, quell'indice è indistinguibile da un carattere astral troncato per solo valore

Come estrarre correttamente in Delphi emoji e testo CJK Extension B?

Chiama Text invece di percorrere Character[] ogni volta che il contenuto testuale effettivo conta, perché Text legge tramite FPDFText_GetText e restituisce una WString completa con corrette coppie surrogate per ogni carattere astral-plane nell'intervallo, invece di un valore a larghezza fissa per indice. Pdf.Text(0, MaxInt), o l'abbreviazione Pdf.Text, estrae un'intera pagina correttamente in un'unica chiamata, e Pdf.Text(StartIndex, Count) estrae un intervallo più piccolo nello stesso modo. Character[] guadagna comunque il proprio posto quando ti servono solo posizione, font, o dati di flag a un indice e non tocchi mai il code point stesso — CharacterOrigin[], FontSize[], e CharacterMapError[] non si preoccupano se il glifo sottostante fosse 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;

Il controllo salta-generati-e-non-mappati in quel ciclo è lo stesso schema usato per l'estrazione di testo semplice in estrarre testo da documenti PDF con il componente PDFium; l'unico cambiamento è l'ultima riga, che scambia un'aggiunta diretta di Character[I] con una chiamata a singolo indice in Text cosicché i caratteri astral arrivino come coppie surrogate complete invece che come segnaposto di rimpiazzo

Dove questo morde realmente: esportazioni chat, nomi personali e font CJK incorporati

Le emoji compaiono ovunque un PDF catturi comunicazione informale: log chat esportati, dump di recensioni da app store, trascrizioni di sistemi di ticketing salvate in PDF per un archivio di conformità. CJK Extension B compare in un luogo più ristretto ma con posta in gioco più alta, nomi personali e di luogo, perché i registri familiari giapponesi, i registri di stato di famiglia cinesi, e i documenti d'identità taiwanesi sono fonti classiche di caratteri che non sono mai entrati nel blocco CJK comune. Una pipeline di buste paga o verifica identità che estrae nomi da documentazione governativa scansionata è esattamente il tipo di carico di lavoro dove un carattere silenziosamente corrotto si trasforma in una corrispondenza fallita piuttosto che in un glitch cosmetico

I rari ideogrammi CJK tendono anche a viaggiare con problemi di font, non solo di codifica, perché un font deve portare un glifo per un code point nell'intervallo U+20000 prima che qualcosa possa essere renderizzato affatto, e pochi font di sistema installati lo fanno. Chiunque stia già percorrendo FontIsEmbedded[] per carattere nel modo descritto in leggere le proprietà dei font PDF con il componente PDFium dovrebbe controllare lo stesso indice per entrambi i problemi insieme: un indice che restituisce U+FFFD da Character[] e segnala un font non incorporato è un documento che non estrarrà né stamperà correttamente quel carattere, e la correzione appartiene a monte, in come il PDF è stato prodotto, non nel tuo codice di estrazione

Le proprietà Character[], Charcode[] e Text descritte qui fanno parte del componente PDFium standard per Delphi e C++Builder