O PDFium Delphi Component monta o pacote XMP para saída PDF/A concatenando fragmentos UTF-8 numa AnsiString e, no Free Pascal 3.2.2, esse pacote deixou silenciosamente de ser UTF-8 válido no instante em que o título de um documento passou a conter um caráter não ASCII. A ISO 19005-1 6.7.2 exige que o stream de metadados seja UTF-8 válido, pelo que o ficheiro falhava a validação. A versão 3.103.1 corrige o próprio encoder, em StringToUtf8. A parte interessante não é o patch. É que uma linha de origem inalterada produzia bytes corretos em Delphi, bytes corretos numa aplicação Lazarus LCL e bytes corrompidos num programa de consola Free Pascal simples compilado a partir da unidade idêntica. Três comportamentos separados das strings Free Pascal têm de alinhar-se antes de isto fazer sentido e cada um deles é defensável por si só
Porque é que o mesmo código de metadados emite bytes diferentes em Delphi e FPC?
Porque string não é o mesmo tipo nos dois compiladores. O FPC 3.2.2 em {$MODE Delphi} compila string como uma AnsiString marcada com DefaultSystemCodePage, enquanto o Delphi a compila como UnicodeString. Cada campo de metadados em TPdfASaveOptions está declarado como string, pelo que Title, Author, Subject, Keywords, Creator e Producer transportam unidades de código UTF-16 num compilador e carateres de um byte mais uma etiqueta de codepage no outro. O mesmo record, payload diferente. Os próprios valores chegam do documento como UTF-16. TPdf.GetTitle e os seus irmãos devolvem WString, que é WideString no FPC e string no Delphi, e SaveAsPdfAToStream preenche qualquer campo de opção vazio a partir do dicionário Info antes de injetar os marcadores. Essa atribuição é uma conversão narrowing no Free Pascal e o RTL executa-a através da codepage da string de destino. Num programa LCL, o LazUTF8 já definiu DefaultSystemCodePage como CP_UTF8, pelo que o narrowing produz UTF-8 e tudo o que vem depois acontece, por acaso, de forma correta. Num programa de consola simples, o mesmo narrowing cai na codepage ANSI e StringToUtf8 copia depois esses octetos sem alterações porque assumiu que já eram UTF-8. Seis bridges de gravação partilham esta forma: SaveAsPdfAToStream, SaveAsPdfUaToStream, SaveAsPdfEToStream, SaveAsPdfXToStream, SaveAsPdfRToStream e SaveAsPdfVTToStream, cada um com o seu próprio record de opções
// PDFium.pas: os accessors do documento são sempre UTF-16
// WString = WideString no FPC, = string (UnicodeString) no Delphi
function TPdf.GetTitle: WString;
// FPdfPdfa.pas: o record de opções de gravação transporta metadados como `string`
TPdfASaveOptions = record
Conformance: TPdfAConformance;
IccProfileData: TBytes;
Title: string; // UnicodeString no Delphi
// AnsiString + DefaultSystemCodePage no 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 preenche campos vazios a partir do dicionário Info
// O narrowing fica agora explícito em vez de ficar implícito:
if Eff.Title = '' then
Eff.Title := WStringToStr(GetTitle);
Encaminhar os seis bridges através de um único helper WStringToStr não altera o que o RTL faz, mas coloca a conversão onde um leitor a consegue ver e eliminou 92 avisos de conversões implícitas que estavam a esconder precisamente esta classe de problema. É a imagem espelhada da corrupção do lado Delphi descrita nas nossas notas sobre armadilhas de compiladores cruzados Delphi e FPC em builds PDFium, onde uma concatenação em Delphi destrói um byte alto que o Free Pascal preserva
Três comportamentos do Free Pascal que derrotam a correção óbvia
A correção óbvia é chamar UTF8Encode e dar o assunto por encerrado. Isso falha três vezes no FPC 3.2.2 em modo Delphi e cada falha é silenciosa
var
W: UnicodeString;
S: string;
U: UTF8String;
R: RawByteString;
Xmp: AnsiString;
begin
// Armadilha 1: em modo Delphi, uma variável UTF8String é uma AnsiString simples,
// pelo que a atribuição recodifica os octetos diretamente de volta para a codepage do host
U := UTF8Encode(W);
// Armadilha 2: S já é uma AnsiString, pelo que UTF8Encode não faz absolutamente nada
R := UTF8Encode(S); // sem decode, sem encode, sem erro
R := UTF8Encode(UnicodeString(S)); // esta chamada codifica mesmo
// Armadilha 3: a concatenação unifica cada operando para a codepage do destino,
// e um destino RawByteString não é exceção
Xmp := Xmp + R;
end;
A primeira armadilha significa que o resultado codificado tem de permanecer na AnsiString ou RawByteString em que foi produzido. Passe-o por uma variável temporária UTF8String no caminho de saída e terá desfeito o trabalho. A segunda é a que fica escondida durante mais tempo, porque UTF8Encode(S) compila, executa, devolve um valor com o comprimento correto e não faz conversão alguma quando o argumento já é uma AnsiString; só alargar primeiro para UnicodeString faz a chamada descodificar alguma coisa. A terceira explica como um encoder correto ainda pode produzir um documento quebrado: BuildXmpBytes acumula o pacote num Xmp: AnsiString local e o Free Pascal converte cada operando de uma concatenação para a codepage da variável de destino, dobrando as sequências de vários bytes de volta para bytes ANSI simples ao entrar
O que garante realmente SetCodePage com False?
SetCodePage(RawByteString(Result), DefaultSystemCodePage, False) volta a etiquetar a string sem tocar num byte. O terceiro parâmetro é Convert; passar False significa "assuma que o payload já está na codepage alvo e apenas altere a etiqueta". É uma mentira sobre o conteúdo, contada deliberadamente: os octetos são realmente UTF-8, mas etiquetá-los como codepage do host é o que impede a concatenação da terceira armadilha de os converter. Eles juntam-se ao buffer XMP como bytes crus e saem do outro lado sem alterações
function StringToUtf8(const S: string): AnsiString;
begin
{$IFDEF UNICODE}
// Delphi: UTF8Encode já produz octetos marcados CP_UTF8 e a
// concatenação numa AnsiString conserva-os
Result := AnsiString(UTF8Encode(S));
{$ELSE}
// FPC: alargue primeiro, ou UTF8Encode não faz nada num argumento AnsiString
Result := UTF8Encode(UnicodeString(S));
// Voltar a etiquetar sem transcoding para que os octetos sobrevivam à concatenação
// nos buffers com etiqueta ANSI que montam pacotes XMP e objetos string PDF
if Length(Result) > 0 then
SetCodePage(RawByteString(Result), DefaultSystemCodePage, False);
{$ENDIF}
end;
Seja claro sobre a fronteira. A retag é exclusiva do FPC e não é uma autorização geral para misturar strings etiquetadas e não etiquetadas. Funciona aqui porque existe exatamente um padrão de consumo a jusante: acrescentar a uma AnsiString e depois escrever o buffer como bytes. Qualquer coisa que tentasse interpretar o valor retagged como texto na codepage do host leria mojibake, e corretamente. A direção inversa é tratada de outra forma e é idêntica nos dois compiladores: etiquete o buffer recebido como CP_UTF8 com SetCodePage(..., False) e depois chame UTF8ToString
Porque é que os testes de regressão traziam a mesma armadilha?
Porque um teste que constrói os seus bytes esperados a partir de um literal de origem está a testar o compilador, não a biblioteca. Uma constante como #$C3#$A9 escrita num ficheiro de código Pascal transporta a codepage de compilação desse ficheiro e, quando é passada a um parâmetro AnsiString, o RTL volta a codificá-la, que é precisamente a conversão em teste. A expectativa tem de ser montada em tempo de execução, byte a byte, e comparada byte a byte, porque = em dois valores AnsiString com etiquetas diferentes reconcilia as codepages antes de comparar e devolve um falso negativo satisfeito
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; // o narrowing em teste
Encoded := StringToUtf8(Narrowed);
finally
SetMultiByteConversionCodePage(Saved);
end;
// 'Caf' + U+00E9 como UTF-8, construído em runtime para que nenhum literal seja recodificado
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;
O harness é ele próprio um programa LCL, pelo que DefaultSystemCodePage é CP_UTF8 e o bug fica invisível até o teste o mudar. SetMultiByteConversionCodePage(1252) dentro de um try..finally reproduz o ambiente de consola simples durante um teste. A verificação end-to-end vai mais longe e afirma as duas direções: o pacote XMP produzido pela injeção de marcadores tem de conter $43 $61 $66 $C3 $A9 e não pode conter $43 $61 $66 $E9, pelo que uma regressão futura que volte à saída crua de um byte falha ruidosamente em vez de produzir um ficheiro que apenas parece plausível num dump hexadecimal. Se trabalha com metadados não latinos, a mesma disciplina de widening rege os casos em emoji e texto CJK que quebram o tratamento WideChar em Delphi
Onde mais cai o narrowing
XMP é a vítima visível, mas qualquer bridge de TBytes para string na mesma base de código estava exposta da mesma forma. Mais duas foram corrigidas na v3.103.1: Utf8BytesToString e StringToUtf8Bytes em FPdfProduction, que fazem round-trip do pacote de datasets XFA através de uma string para que MergePdfXfaDatasets possa substituir valores ligados, e BytesToUtf8 em FPdfTrustedList, que descodifica XML de listas europeias de confiança depois de remover a marca de ordem de bytes. Ambas colocam agora o buffer temporariamente numa RawByteString, etiquetam-no como CP_UTF8 sem o converter e descodificam-no com UTF8ToString. Um módulo já era imune e vale a pena copiar a razão. O writer XFDF declara o seu próprio tipo de texto como XFDFString, que resolve para WideString no FPC e UnicodeString no Delphi, pelo que o seu encoder nunca vê uma AnsiString com codepage de todo. Essa é a correção estrutural: mantenha o texto num tipo UTF-16 até ao ponto exato da serialização e deixe uma única função estreita ser dona da conversão para bytes. Cada bug desta família veio de um campo string sentado no meio de um pipeline que era UTF-16 numa ponta e octetos na outra
O que verificar no seu próprio código PDF com dois compiladores?
Se distribui Object Pascal que corre nos dois compiladores e escreve metadados num PDF conforme as normas, quatro verificações encontram a maior parte desta classe antes de um validador
- Faça grep por
UTF8Encodecom um argumentostring. No FPC essa chamada não faz nada e é a linha de maior rendimento para auditar - Trate todas as variáveis
UTF8Stringcomo suspeitas em modo Delphi. Aí são simplesAnsiStringe atribuir-lhes bytes codificados volta a recodificá-los - Execute pelo menos uma regressão sob
SetMultiByteConversionCodePagecom uma codepage de um byte. Um harness de testes LCL corre comCP_UTF8e nunca reproduzirá um programa de consola simples - Construa os vetores de bytes esperados em tempo de execução e compare-os octeto a octeto. Literais de origem e
=passam ambos pela reconciliação de codepages e escondem o defeito que está a tentar encontrar
Nada disto é folclore exótico do Free Pascal. É o custo normal de uma linguagem que manteve vivo um tipo de string orientado a bytes ao lado de um tipo UTF-16 e os dois compiladores fizeram escolhas razoáveis, mas diferentes, sobre o significado de string. A consequência prática para o trabalho com PDF é estreita e nítida: metadados que leem bem no seu IDE podem chegar ao pacote XMP como UTF-8 inválido e a ISO 19005-1 6.7.2 não quer saber qual compilador os colocou lá. Se está a construir um pipeline de arquivo, a camada de codificação merece tanta atenção como o resto do workflow de conformidade de arquivo PDF/A que a envolve. O PDFium Delphi Component distribui estas conversões como parte da biblioteca, pelo que SaveAsPdfA e os seus cinco irmãos de normas emitem metadados UTF-8 conformes em Delphi, Lazarus e builds Free Pascal simples sem qualquer configuração de codepage do chamador. A documentação completa da API e a release atual estão na página do produto PDFium Delphi Component