Artículo técnico

Off-by-one de PDFDocEncoding rompe la importación FDF

Importa un FDF en un formulario y los valores de los campos vuelven sutilmente mal: una bala llega como una daga, una elipsis como un em dash. PDFium Component rastreó toda esa familia de síntomas hasta una sola fila de su tabla PDFDocEncoding — el bloque de caracteres especiales del Anexo D.2 de ISO 32000-1 empieza en $80, no en $7F

¿Por qué una importación FDF devuelve el carácter vecino?

Porque una tabla de codificación corrida una posición sigue siendo una tabla de apariencia perfectamente válida, y cada lookup en ella tiene éxito. El bloque que el Anexo D.2 asigna de $80 a $9E se había transcrito una fila antes, así que el byte $80 resolvía a la entrada que pertenece a $81, el byte $81 a la entrada de $82, y así por diecinueve posiciones de código consecutivas. Nada lanzaba, nada se logueaba, y ningún campo se rechazó jamás — TPdf.ImportFDF reportaba el mismo conteo de campos de siempre

El daño era uniforme y silencioso. Un byte con $80, que el Anexo D.2 define como BULLET (U+2022), volvía como DAGGER (U+2020). El byte $83, HORIZONTAL ELLIPSIS (U+2026), volvía como EM DASH (U+2014). El byte $8D, LEFT DOUBLE QUOTATION MARK (U+201C), volvía como RIGHT DOUBLE QUOTATION MARK (U+201D) — el caso que nadie nota, porque una comilla rizada equivocada igual parece una comilla rizada en un campo de formulario. Los nombres de campo sufrieron el mismo destino que los valores de campo, que es peor: un nombre que ya no coincide con el widget del documento falla silenciosamente al aplicarse

Diagrama del off-by-one de PDFDocEncoding detrás del daño en la importación FDF de PDFium Component: el Anexo D.2 de ISO 32000-1 mapea el byte $80 a BULLET, $83 a HORIZONTAL ELLIPSIS y $8D a una comilla doble de apertura, mientras que la tabla transcrita una fila antes devolvía DAGGER, EM DASH y la comilla de cierre para los mismos bytes, con todos los lookups teniendo éxito igual
Una tabla de codificación corrida sigue siendo una tabla de apariencia válida en la que todo lookup tiene éxito, así que una bala llega como una daga y un nombre de campo que ya no coincide falla silenciosamente al aplicarse

Las cadenas de texto PDF llevan dos codificaciones distintas

ISO 32000-1 §7.9.2.2 define el tipo de cadena de texto como una de exactamente dos codificaciones, elegida inspeccionando los primeros dos bytes en lugar de cualquier entrada de diccionario. Una cadena que abre con el byte order mark FE FF es UTF-16BE; cualquier otra cosa es PDFDocEncoding. Esa sola rama decide cuál de dos decoders completamente distintos corre, y PDFium Component la aplica dentro del parser FDF antes de tocar un solo carácter

Diagrama de la rama de cadenas de texto de la sección 7.9.2.2 de ISO 32000-1 en PDFium Component: una cadena que abre con el byte order mark FE FF se decodifica como UTF-16BE con pares de surrogates validados, cualquier otra cosa va a la tabla de un solo byte FdfPdfDocEncodingChar, que es por lo que los writers UTF-16 y los nombres puramente ASCII nunca expusieron el bloque corrido hasta que llegó un símbolo tipográfico
Inspeccionar los primeros dos bytes en lugar de cualquier entrada de diccionario decide qué decoder corre, así que las herramientas que escriben UTF-16BE por default nunca ejercitaron la única tabla que estaba mal
// Cadena de texto FDF: UTF-16 cuando el BOM está presente, PDFDocEncoding en caso contrario
if (Length(Raw) >= 2) and
   (((Byte(Raw[1]) = $FE) and (Byte(Raw[2]) = $FF)) or
    ((Byte(Raw[1]) = $FF) and (Byte(Raw[2]) = $FE))) then
begin
  BigEndian := Byte(Raw[1]) = $FE;
  // ... decodificar unidades de código UTF-16, validando los pares suplentes
end
else
begin
  SetLength(Result, Length(Raw));
  for I := 1 to Length(Raw) do
    Result[I] := FdfPdfDocEncodingChar(Byte(Raw[I]));
end;

Esta es la razón por la que el bug se escondió tanto tiempo en las pruebas ordinarias. Cualquier FDF producido por una herramienta que escribe UTF-16BE por default nunca llega a FdfPdfDocEncodingChar en absoluto, así que la tabla corrida queda latente. Solo las cadenas de un solo byte — que es lo que muchos generadores de FDF emiten para nombres de campo latinos sencillos, porque son más cortas — bajan por la rama donde la tabla importa. Los nombres de campo puramente ASCII se decodifican bien igual, porque por debajo de $80 PDFDocEncoding y ASCII coinciden. Necesitas una cadena de un solo byte que contenga un símbolo tipográfico antes de que algo se vea mal

La tabla corregida, y los dos anclajes que la fijan

PDFium Component ahora escribe el límite en el propio FdfPdfDocEncodingChar, porque los dos extremos del bloque son exactamente lo que un implementador hace mal. $7F es DEL y no tiene ningún significado en PDFDocEncoding; el bloque abre en $80 con BULLET y corre hasta $9E con zcaron; $9F está sin asignar; y el signo de Euro se sienta solo en $A0

Diagrama de los anclajes de límite que fijan la tabla del Anexo D.2 en PDFium Component: $7F es DEL sin significado en PDFDocEncoding, $80 abre el bloque con BULLET, $9E lo cierra con zcaron, $9F está sin asignar, y el signo de Euro se sienta solo en $A0, así que un corrimiento de todo el bloque no puede satisfacer los extremos y los huecos a la vez
Verificar la primera fila, la última fila y los huecos fija ambos extremos a la vez, que es precisamente la prueba que un corrimiento de todo el bloque no puede sobrevivir
function FdfPdfDocEncodingChar(Value: Byte): WideChar;
begin
  case Value of
    // ISO 32000-1 Anexo D.2. El bloque va de $80, no de $7F: $7F es DEL y
    // no tiene significado en PDFDocEncoding, y el Euro está en $A0, que la
    // rama por defecto devolvería de otro modo como espacio sin separación
    $7F: Result := WideChar($FFFD);
    $80: Result := WideChar($2022);   // BULLET, primera fila del bloque
    $81: Result := WideChar($2020);   // DAGGER
    // ... $82 through $9D
    $9E: Result := WideChar($017E);   // zcaron, última fila definida
    $9F: Result := WideChar($FFFD);   // sin asignar en el Anexo D.2
    $A0: Result := WideChar($20AC);   // EURO SIGN
  else
    Result := WideChar(Value);
  end;
end;

Mira lo que el corrimiento les hizo a esos dos extremos, porque ahí es donde un off-by-one deja de ser cosmético. zcaron estaba en $9E en la especificación pero en $9D en la tabla corrida, así que un byte de documento de $9E caía más allá de la última fila que alguien había transcrito y se deslizaba directo al fallback de identidad, devolviendo un carácter de control C1 en lugar de una letra. Y $A0, el Euro, nunca se escribió en la tabla en primer lugar — la rama de identidad lo devolvía como U+00A0 NO-BREAK SPACE, un carácter que sobrevive toda validación posterior, no imprime nada, y nunca coincide con el nombre de campo que estás buscando

¿Cómo pruebas una tabla de codificación transcrita?

Prueba la primera fila, la última fila y los huecos — no una muestra del medio. Esta es la parte de la corrección que vale la pena llevar a cualquier otra tabla que copies de una especificación, porque una verificación puntual a mitad del bloque es precisamente la prueba que un corrimiento de todo el bloque sobrevive. Verificar que $8D da una comilla doble de apertura no prueba nada cuando $8C también da algo plausible; verificar que $80 da BULLET y $9E da zcaron fija ambos extremos, y un bloque corrido no puede satisfacer ambos a la vez

procedure CheckPdfDocEncodingAnchors;
begin
  // Ambos extremos del bloque especial del Anexo D.2: un desplazamiento de todo el bloque los rompe
  Assert(FdfPdfDocEncodingChar($80) = WideChar($2022), 'first row: BULLET');
  Assert(FdfPdfDocEncodingChar($9E) = WideChar($017E), 'last row: zcaron');
  // Las posiciones de código que la especificación deja indefinidas no deben decodificarse como una letra
  Assert(FdfPdfDocEncodingChar($7F) = WideChar($FFFD), '$7F is DEL, unmapped');
  Assert(FdfPdfDocEncodingChar($9F) = WideChar($FFFD), '$9F is unassigned');
  // La entrada aislada arriba del bloque, fácil de omitir por completo
  Assert(FdfPdfDocEncodingChar($A0) = WideChar($20AC), 'EURO SIGN');
end;

Las posiciones indefinidas merecen sus propios asserts en lugar de saltarse como poco interesantes. Un off-by-one tiene que poner algo en algún lado, y donde lo pone casi siempre es una posición de código que la especificación dejó vacía — $7F al frente, $9F al final. Una prueba que solo verifica posiciones definidas puede pasar mientras la tabla está mal en ambas direcciones; una prueba que fija los huecos atrapa el corrimiento en la primera corrida

Qué cuesta esto en un flujo de formularios de ida y vuelta

Una importación FDF que reescribe en silencio un carácter por símbolo es peor que una que falla, porque el fallo aparece aguas abajo y parece otra cosa. Los nombres de campo son el filo aquí. TPdf.ImportFDF compara los nombres entrantes contra los widgets del documento abierto, y un nombre que decodificó un carácter mal simplemente no coincide — el campo queda vacío, el valor de retorno igual cuenta el campo como parseado, y el reporte que sale del pipeline queda corto de un valor sin nada en el log que explique por qué. Cualquiera que construya un loop de exportar-y-reimportar del tipo descrito en exportar e importar datos de formulario XFDF y anotaciones con PDFium Component debería hacer un round trip de un nombre de campo que contenga un símbolo tipográfico, no solo ASCII puro, antes de confiar en el loop

También vale la pena ser honesto sobre los límites de la corrección. La tabla ahora coincide con el Anexo D.2 para los 256 valores de byte, lo que hace que las cadenas FDF de un solo byte se decodifiquen bien, pero PDFDocEncoding en sí cubre un repertorio latino y nada más — cualquier cosa fuera de eso tiene que llegar como UTF-16BE con un BOM, que es exactamente lo que pretende §7.9.2.2. Y una cadena UTF-16 todavía tiene sus propios peligros separados del lado de Delphi, porque un code point más allá de U+FFFF necesita un par de surrogates en lugar de un WideChar, el problema cubierto en por qué los emojis y los caracteres CJK rompen WideChar en Delphi. El punto más amplio se generaliza más allá de las tablas de codificación: un parser PDF puede rechazar algo que debería aceptar, lo que encuentras de inmediato, o aceptar algo y equivocarse en silencio, lo que encuentras en producción. Las trampas de name-token descritas en parsear diccionarios PDF con seguridad están en esa segunda categoría, y también lo está una tabla corrida una fila — el camino de código tiene éxito, el valor de retorno se ve razonable, y solo una prueba escrita contra la especificación en lugar de contra la implementación dice lo contrario

ImportFDF, ExportFDF e ImportXFDF en TPdf, junto con los parsers FDF y XFDF que hay detrás, son parte del PDFium Component estándar para Delphi y C++Builder