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
UTF8Encodecon un argumentostring. En FPC esa llamada no hace nada y es la línea con mayor rendimiento de auditoría - Trate como sospechosa cualquier variable
UTF8Stringen modo Delphi. Allí es unAnsiStringnormal y asignarle bytes codificados los transcodifica de vuelta - Ejecute al menos una regresión bajo
SetMultiByteConversionCodePagecon un codepage de un byte. Un harness de pruebas LCL se ejecuta enCP_UTF8y 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