Artículo técnico

El emoji y los caracteres CJK de PDFium rompen WideChar en Delphi

Extraed un emoji o un nombre de un registro familiar japonés de un PDF como texto, y la salida muestra un cuadro, un signo de interrogación, o nada en absoluto donde debería estar el carácter. La propiedad Character[] del componente PDFium suele ser la causa: lee cada glifo mediante FPDFText_GetUnicode, que devuelve un punto de código Unicode completo como un valor sin signo de 32 bits, y después lo expone a Delphi como un único WideChar de 16 bits. Cualquier punto de código más allá de U+FFFF no puede hacer ese viaje de una pieza, y la corrupción nunca aparece mientras estáis mirando la página renderizada, porque el renderizado y la extracción de texto se ejecutan por vías de código separadas dentro de PDFium: un documento puede mostrar su emoji perfectamente y aun así entregaros basura en cuanto leáis Character[] en un bucle y construyáis una cadena a partir de él

El Plano Multilingüe Básico y por qué WideChar se detiene en U+FFFF

El WideChar de Delphi es un tipo de 16 bits que solo puede contener una unidad de código UTF-16. El Plano Multilingüe Básico de Unicode, el rango U+0000 a U+FFFF, encaja exactamente ahí, razón por la cual el latín, el cirílico, el griego y el bloque CJK Unified Ideographs común hacen todos el viaje de ida y vuelta a través de un único WideChar sin incidentes. Dos familias de caracteres quedan rutinariamente fuera de ese plano en documentos reales: el emoji, muchos de ellos en el bloque Emoticons que empieza en U+1F600, y los ideogramas CJK poco frecuentes de CJK Unified Ideographs Extension B, el rango de U+20000 a U+2A6DF reservado para caracteres chinos, japoneses y coreanos menos comunes, incluidos muchos nombres personales y toponímicos. UTF-16 gestiona cualquier cosa por encima de U+FFFF con un par sustituto (surrogate pair), dos unidades de código de 16 bits, un sustituto alto en el rango $D800 a $DBFF seguido de un sustituto bajo en $DC00 a $DFFF, que juntos codifican un único punto de código, y la aritmética detrás de ese emparejamiento es lo bastante fija como para demostrarla directamente en 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;

Alimentad U+1F600, el emoji de cara sonriente, a través de esa función y el resultado es un sustituto alto de $D83D y un sustituto bajo de $DE00, dos valores de 16 bits, no uno. Ninguna de las dos mitades significa nada por sí sola; un $D83D solitario alojado en una cadena sin ningún $DE00 detrás es un sustituto colgante, y la mayoría del código de manejo de texto que se encuentra con uno o bien lo descarta, o bien lo sustituye por un glifo de reemplazo, o bien lanza un error

¿Por qué devuelve FPDFText_GetUnicode un valor que Character[] no puede contener?

FPDFText_GetUnicode devuelve un LongWord, un valor completo de 32 bits, porque la codificación de texto de PDF ya lleva el valor escalar Unicode completo para cada glifo. El CMap ToUnicode de un PDF asocia códigos de carácter a texto Unicode, y cuando un glifo representa lo que informalmente se llama un carácter de plano astral, cualquier cosa más allá del Plano Multilingüe Básico, esa asociación es un punto de código completo, no un fragmento de 16 bits. PDFium lo decodifica internamente de vuelta a un valor escalar y lo devuelve a través de la frontera de la DLL mediante FPDFText_GetUnicode, y esa frontera es exactamente donde un valor de 32 bits tiene que convertirse en algo que una propiedad Delphi pueda devolver a vuestro código

La implementación obvia es WideChar(FPDFText_GetUnicode(TextPage, Index)), y también es la equivocada. Un cast forzado desde un valor de 32 bits a un tipo de 16 bits conserva solo los 16 bits bajos y descarta el resto en silencio, sin ninguna excepción y sin ninguna comprobación de rango. Para U+1F600 eso significa conservar $F600 y perder el hecho de que el valor real estuviera alguna vez por encima de U+FFFF, lo que produce una unidad de código que ni siquiera es un sustituto colgante válido, solo un carácter del Plano Multilingüe Básico sin relación que resulta compartir esos bits bajos. Concatenad unos cuantos miles de esos en una cadena y el código posterior ya no tiene forma de distinguir un carácter corrompido de uno legítimo

Qué devuelven ahora Character[] y Charcode[] para puntos de código de plano astral

Las propiedades Character[] y Charcode[] del componente PDFium devuelven U+FFFD, el carácter de reemplazo de Unicode, siempre que el punto de código subyacente supere U+FFFF, en lugar de truncarlo silenciosamente. Esa protección se sitúa directamente dentro del getter de propiedad que hay detrá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 en lugar de un fragmento truncado es una solución deliberada y acotada, no un rediseño. Character[] y Charcode[] están tipadas como WideChar tanto en TPdf como en TPdfView, y ampliar ese tipo de retorno para llevar un punto de código completo rompería a cada llamador existente que espera que un glifo por índice signifique un valor de 16 bits. U+FFFD es el propio marcador de posición designado por el Estándar Unicode exactamente para esta situación, así que un llamador que compruebe su presencia recibe una señal definida y documentada en lugar de datos silenciosamente equivocados. Un caso límite que merece la pena conocer: U+FFFD también es un carácter legítimo por derecho propio, así que en el raro documento que ya contenga un glifo de carácter de reemplazo genuino, ese índice es indistinguible por su valor de un carácter astral truncado

¿Cómo se extrae correctamente en Delphi texto con emoji y con CJK Extension B?

Llamad a Text en lugar de recorrer Character[] siempre que el contenido textual real importe, porque Text lee a través de FPDFText_GetText y devuelve un WString completo con pares sustitutos correctos para cada carácter de plano astral dentro del rango, en lugar de un valor de ancho fijo por índice. Pdf.Text(0, MaxInt), o el atajo Pdf.Text, extrae una página entera correctamente en una llamada, y Pdf.Text(StartIndex, Count) extrae un rango más pequeño del mismo modo. Character[] sigue ganándose su sitio cuando solo necesitáis datos de posición, fuente o indicador en un índice y nunca tocáis el propio punto de código; a CharacterOrigin[], FontSize[] y CharacterMapError[] no les importa si el glifo subyacente 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;

La comprobación de omitir-generado-y-sin-mapear en ese bucle es el mismo patrón usado para la extracción de texto plano en la extracción de texto de documentos PDF con el componente PDFium; el único cambio es la última línea, que cambia un añadido directo de Character[I] por una llamada de un único índice a Text para que los caracteres astrales lleguen como pares sustitutos completos en lugar de marcadores de reemplazo

Dónde muerde esto realmente: exportaciones de chat, nombres personales y fuentes CJK incrustadas

El emoji aparece en cualquier sitio donde un PDF capture comunicación informal: registros de chat exportados, volcados de reseñas de tiendas de aplicaciones, transcripciones de sistemas de tickets guardadas a PDF para un archivo de cumplimiento normativo. CJK Extension B aparece en un lugar más estrecho pero de mayor riesgo, nombres personales y toponímicos, porque los registros familiares japoneses, los registros de empadronamiento chinos y los documentos de identidad taiwaneses son fuentes clásicas de caracteres que nunca llegaron al bloque CJK común. Un pipeline de nómina o de verificación de identidad que extrae nombres de documentación gubernamental escaneada es exactamente el tipo de carga de trabajo donde un carácter silenciosamente estropeado se convierte en una coincidencia fallida en lugar de en un fallo cosmético

Los ideogramas CJK poco frecuentes también suelen viajar con problemas de fuente, no solo de codificación, porque una fuente tiene que llevar un glifo para un punto de código del rango U+20000 antes de que pueda renderizarse nada en absoluto, y pocas fuentes de sistema instaladas lo hacen. Cualquiera que ya recorra FontIsEmbedded[] por carácter del modo que describe la lectura de propiedades de fuente PDF con el componente PDFium debería comprobar el mismo índice para ambos problemas a la vez: un índice que devuelve U+FFFD de Character[] y reporta una fuente no incrustada es un documento que no extraerá ni imprimirá correctamente ese carácter, y la solución pertenece aguas arriba, en cómo se produjo el PDF, no en vuestro código de extracción

Las propiedades Character[], Charcode[] y Text descritas aquí forman parte del componente PDFium estándar para Delphi y C++Builder