Artículo técnico

Trampas de codepage de FPC en metadatos XMP PDF/A

El componente PDFium para Delphi ensambla el paquete XMP de la salida PDF/A concatenando fragmentos UTF-8 en un AnsiString, y en Free Pascal 3.2.2 ese paquete dejó silenciosamente de ser UTF-8 válido en cuanto el título de un documento llevó un carácter no ASCII. ISO 19005-1 6.7.2 exige que el stream de metadatos sea UTF-8 válido, así que el archivo falló la validación. La versión 3.103.1 corrige el propio encoder, en StringToUtf8. Lo interesante no es el parche. Es que una misma línea de código sin cambios producía bytes correctos bajo Delphi, bytes correctos en una aplicación Lazarus LCL y bytes corruptos en un programa de consola Free Pascal compilado desde la misma unidad. Hay que alinear tres comportamientos independientes de las strings de Free Pascal para entenderlo, y cada uno es defendible por separado

¿Por qué el mismo código de metadatos produce bytes distintos en Delphi y FPC?

Porque string no es el mismo tipo en los dos compiladores. FPC 3.2.2 en {$MODE Delphi} compila string como un AnsiString etiquetado con DefaultSystemCodePage, mientras que Delphi lo compila como UnicodeString. Cada campo de metadatos de TPdfASaveOptions se declara como string, así que Title, Author, Subject, Keywords, Creator y Producer llevan unidades de código UTF-16 en un compilador y caracteres de un byte más una etiqueta de codepage en el otro. Mismo record, mismo campo, payload distinto. Los valores llegan del documento en UTF-16. TPdf.GetTitle y sus hermanos devuelven WString, que es WideString en FPC y string (UnicodeString) en Delphi, y SaveAsPdfAToStream rellena cualquier campo vacío de las opciones desde el diccionario Info antes de inyectar las marcas. Esa asignación es una conversión de reducción en Free Pascal, y el RTL la ejecuta mediante el codepage de la string de destino. En un programa LCL, LazUTF8 ya ha establecido DefaultSystemCodePage a CP_UTF8, así que la reducción produce UTF-8 y todo lo posterior resulta correcto por casualidad. En un programa de consola, la misma reducción cae en el codepage ANSI, y StringToUtf8 después copia esos octetos sin cambios porque suponía que ya eran UTF-8. Seis puentes de guardado tienen esta forma: SaveAsPdfAToStream, SaveAsPdfUaToStream, SaveAsPdfEToStream, SaveAsPdfXToStream, SaveAsPdfRToStream y SaveAsPdfVTToStream, cada uno con su propio record de opciones

// PDFium.pas: los accessors del documento siempre son UTF-16
//   WString = WideString en FPC, = string (UnicodeString) en Delphi
function TPdf.GetTitle: WString;

// FPdfPdfa.pas: el record de opciones de guardado lleva los metadatos como `string`
TPdfASaveOptions = record
  Conformance: TPdfAConformance;
  IccProfileData: TBytes;
  Title: string;      // UnicodeString en Delphi
                      // AnsiString + DefaultSystemCodePage en FPC
  Author: string;
  Subject: string;
  Keywords: string;
  Creator: string;
  Producer: string;
  CreationDate: string;
  ModDate: string;
  DocumentId: TBytes;
  InstanceId: TBytes;
  class function Default: TPdfASaveOptions; static;
end;

// SaveAsPdfAToStream rellena los campos vacíos desde el diccionario Info.
// La reducción ahora queda escrita en lugar de permanecer implícita:
if Eff.Title = '' then
  Eff.Title := WStringToStr(GetTitle);

Dirigir los seis puentes mediante un único helper WStringToStr no cambia lo que hace el RTL, pero coloca la conversión donde el lector puede verla y eliminó 92 warnings de conversión implícita que estaban ocultando precisamente esta clase de problema. Es la imagen especular de la corrupción del lado Delphi descrita en nuestras notas sobre los problemas entre compiladores Delphi y FPC en builds de PDFium, donde una concatenación en Delphi destruye un byte alto que Free Pascal conserva

Tres comportamientos de Free Pascal que derrotan la solución obvia

La solución obvia es llamar a UTF8Encode y dar el asunto por terminado. En FPC 3.2.2 en modo Delphi falla tres veces y cada fallo es silencioso

var
  W: UnicodeString;
  S: string;
  U: UTF8String;
  R: RawByteString;
  Xmp: AnsiString;
begin
  // Trampa 1: en modo Delphi una variable UTF8String es un AnsiString normal,
  // así que la asignación vuelve a transcodificar los octetos al codepage del host
  U := UTF8Encode(W);

  // Trampa 2: S ya es un AnsiString, así que UTF8Encode no hace absolutamente nada
  R := UTF8Encode(S);                 // sin decodificar, sin codificar, sin error
  R := UTF8Encode(UnicodeString(S));  // esta sí codifica realmente

  // Trampa 3: la concatenación unifica cada operando al codepage de destino,
  // y un destino RawByteString no es una excepción
  Xmp := Xmp + R;
end;

La primera trampa significa que el resultado codificado tiene que permanecer en el AnsiString o RawByteString en el que se produjo. Páselo por una temporal UTF8String al salir y habrá deshecho el trabajo. La segunda es la que más tiempo permanece oculta, porque UTF8Encode(S) compila, se ejecuta, devuelve un valor de la longitud correcta y no hace ninguna conversión cuando su argumento ya es un AnsiString; solo ensanchar primero a UnicodeString hace que la llamada decodifique algo. La tercera es la razón por la que un encoder correcto todavía puede producir un documento roto: BuildXmpBytes acumula el paquete en una local Xmp: AnsiString, y Free Pascal convierte cada operando de una concatenación al codepage de la variable de destino, plegando las secuencias multibyte otra vez en bytes ANSI durante la entrada

¿Qué garantiza realmente SetCodePage con False?

SetCodePage(RawByteString(Result), DefaultSystemCodePage, False) vuelve a etiquetar la string sin tocar un byte. El tercer parámetro es Convert; pasar False significa «suponer que el payload ya está en el codepage de destino y cambiar solo la etiqueta». Es una mentira sobre el contenido, contada deliberadamente: los octetos son realmente UTF-8, pero etiquetarlos como codepage del host es lo que impide que la concatenación de la tercera trampa los convierta. Se unen al buffer XMP como bytes sin procesar y salen por el otro lado sin cambios

function StringToUtf8(const S: string): AnsiString;
begin
{$IFDEF UNICODE}
  // Delphi: UTF8Encode ya produce octetos etiquetados como CP_UTF8 y la
  // concatenación en un AnsiString los conserva
  Result := AnsiString(UTF8Encode(S));
{$ELSE}
  // FPC: ensanchar primero, o UTF8Encode no hace nada con un argumento AnsiString
  Result := UTF8Encode(UnicodeString(S));
  // Volver a etiquetar sin transcodificar, para que los octetos sobrevivan a la
  // concatenación en los buffers con etiqueta ANSI que ensamblan paquetes XMP y objetos de string PDF
  if Length(Result) > 0 then
    SetCodePage(RawByteString(Result), DefaultSystemCodePage, False);
{$ENDIF}
end;

Conviene dejar claro el límite. El retagging es solo para FPC y no es una autorización general para mezclar strings etiquetadas y no etiquetadas. Funciona aquí porque aguas abajo solo existe un patrón de consumo: añadir a un AnsiString y escribir después el buffer como bytes. Cualquier cosa que intentara interpretar el valor retagged como texto en el codepage del host leería mojibake, correctamente. La dirección inversa se gestiona de la otra forma y es idéntica en ambos compiladores: etiquetar el buffer entrante como CP_UTF8 con SetCodePage(..., False) y después llamar a UTF8ToString

¿Por qué las pruebas de regresión llevaban la misma trampa?

Porque una prueba que construye sus bytes esperados a partir de un literal fuente está probando el compilador, no la biblioteca. Una constante como #$C3#$A9 escrita en un archivo fuente Pascal lleva el codepage de compilación de ese archivo y, cuando se pasa a un parámetro AnsiString, el RTL vuelve a codificarla, precisamente la conversión que se está probando. La expectativa tiene que ensamblarse en tiempo de ejecución, byte a byte, y compararse byte a byte, porque = sobre dos valores AnsiString con etiquetas distintas reconcilia los codepages antes de comparar y devuelve un alegre falso negativo

function BytesPattern(const Values: array of Byte): AnsiString;
var
  I: Integer;
begin
  SetLength(Result, Length(Values));
  for I := 0 to High(Values) do
    Result[I + 1] := AnsiChar(Values[I]);
end;

procedure TPdfATests.StringToUtf8_AnsiCodePageString_EncodesUtf8;
var
  Saved: Word;
  Wide: WideString;
  Narrowed: string;
  Encoded, ExpectedUtf8: AnsiString;
begin
  Saved := DefaultSystemCodePage;
  try
    SetMultiByteConversionCodePage(1252);
    Wide := WideChar($0043) + WideChar($0061) + WideChar($0066) + WideChar($00E9);
    Narrowed := Wide;                    // la reducción que se está probando
    Encoded := StringToUtf8(Narrowed);
  finally
    SetMultiByteConversionCodePage(Saved);
  end;
  // 'Caf' + U+00E9 como UTF-8, construido en tiempo de ejecución para que ningún literal se recodifique
  ExpectedUtf8 := BytesPattern([$43, $61, $66, $C3, $A9]);
  AssertTrue('StringToUtf8 must emit UTF-8 for a string carrying a non-UTF-8 codepage',
    SameOctets(Encoded, ExpectedUtf8));
end;

El harness es un programa LCL, así que DefaultSystemCodePage es CP_UTF8 y el bug resulta invisible hasta que la prueba lo cambia. SetMultiByteConversionCodePage(1252) dentro de un try..finally reproduce el entorno de consola normal durante una sola prueba. La comprobación end-to-end va más lejos y afirma las dos direcciones: el paquete XMP producido por la inyección de marcas debe contener $43 $61 $66 $C3 $A9 y no debe contener $43 $61 $66 $E9, así una futura regresión que vuelva a la salida cruda de un byte falla con claridad en lugar de producir un archivo que solo parezca plausible en un volcado hexadecimal. Si trabaja con metadatos no latinos, la misma disciplina de ensanchamiento gobierna los casos de emoji y texto CJK que rompen la gestión de WideChar en Delphi

Dónde aparece también la reducción

XMP es la víctima visible, pero cualquier puente de TBytes a string en la misma base de código estaba igualmente expuesto. En v3.103.1 se corrigieron dos más: Utf8BytesToString y StringToUtf8Bytes en FPdfProduction, que hacen round-trip del paquete de datasets XFA mediante una string para que MergePdfXfaDatasets pueda sustituir valores enlazados, y BytesToUtf8 en FPdfTrustedList, que decodifica XML de listas de confianza europeas después de eliminar la marca de orden de bytes. Ambos preparan ahora el buffer en un RawByteString, lo etiquetan como CP_UTF8 sin convertirlo y lo decodifican con UTF8ToString. Un módulo ya era inmune, y merece la pena copiar el motivo. El writer XFDF declara su propio tipo de texto como XFDFString, que se resuelve como WideString bajo FPC y UnicodeString bajo Delphi, así que su encoder nunca ve un AnsiString etiquetado con codepage. Esa es la corrección estructural: conservar el texto en un tipo UTF-16 hasta el punto exacto de serialización y dejar que una única función estrecha sea propietaria de la conversión a bytes. Cada bug de esta familia procedía de un campo string situado en mitad de una pipeline que por lo demás era UTF-16 en un extremo y octetos en el otro

Qué debe comprobar en su propio código PDF de doble compilador

Si distribuye Object Pascal que funciona en ambos compiladores y escribe metadatos en un PDF conforme a los estándares, cuatro comprobaciones detectan la mayoría de esta clase de problemas antes que un validador

  • Busque UTF8Encode con un argumento string. En FPC esa llamada no hace nada y es la línea con mayor rendimiento de auditoría
  • Trate como sospechosa cualquier variable UTF8String en modo Delphi. Allí es un AnsiString normal y asignarle bytes codificados los transcodifica de vuelta
  • Ejecute al menos una regresión bajo SetMultiByteConversionCodePage con un codepage de un byte. Un harness de pruebas LCL se ejecuta en CP_UTF8 y nunca reproduce un programa de consola normal
  • Construya los vectores de bytes esperados en tiempo de ejecución y compárelos octeto a octeto. Los literales fuente y = pasan ambos por la reconciliación de codepages y ocultarán el defecto que busca

Nada de esto es una rareza exótica de Free Pascal. Es el coste normal de un lenguaje que mantuvo vivo un tipo de string orientado a bytes junto a otro UTF-16, y los dos compiladores tomaron decisiones razonables pero distintas sobre qué debía significar string. La consecuencia práctica para PDF es estrecha y clara: unos metadatos que se leen bien en el IDE pueden llegar al paquete XMP como UTF-8 inválido, y a ISO 19005-1 6.7.2 le da igual qué compilador los haya puesto ahí. Si está construyendo una pipeline de archivo, la capa de codificación merece tanta atención como el resto del flujo de cumplimiento de PDF/A que la rodea. El componente PDFium para Delphi distribuye estas conversiones como parte de la biblioteca, por lo que SaveAsPdfA y sus cinco hermanos de estándares emiten metadatos UTF-8 conformes en Delphi, Lazarus y builds de Free Pascal normal, sin que el caller tenga que configurar ningún codepage. La documentación completa de la API y la release actual están en la página del producto PDFium Delphi Component