Artigo Técnico

Codepages do FPC quebram metadados XMP PDF/A em Delphi

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

O pipeline de metadados do PDFium Delphi Component em que TPdf.GetTitle devolve um WString UTF-16 em ambos os compiladores, a string do Delphi mantém-se UnicodeString enquanto o Free Pascal faz o narrowing através de DefaultSystemCodePage, pelo que um programa LCL produz UTF-8 válido enquanto um build simples de consola dobra o título XMP em bytes simples inválidos
Uma única linha de origem inalterada emite bytes XMP diferentes, porque string significa UnicodeString no Delphi mas uma AnsiString etiquetada com DefaultSystemCodePage no Free Pascal, e só o ambiente de consola reduz os metadados a UTF-8 inválido
// 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

Três armadilhas silenciosas do Free Pascal que derrotam uma correção UTF8Encode ingénua no encoder XMP do PDFium Delphi Component: uma temporária UTF8String retranscodifica os octetos em modo Delphi, um argumento AnsiString torna a chamada um no-op, e a concatenação unifica todos os operandos para a codepage de destino
Cada armadilha falha silenciosamente no FPC 3.2.2 em modo Delphi, razão pela qual um encoder correto ainda pode dobrar sequências UTF-8 de vários bytes para bytes ANSI únicos, enquanto uma concatenação monta o pacote XMP numa AnsiString local

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

A correção StringToUtf8 para o PDFium Delphi Component no Free Pascal, em que UTF8Encode corre depois de alargar para UnicodeString e SetCodePage com Convert False volta a etiquetar o buffer sem mover um byte, pelo que os octetos UTF-8 sobrevivem à concatenação no buffer XMP etiquetado como ANSI em vez de dobrarem em bytes únicos inválidos
Passar False no parâmetro Convert conta uma mentira deliberada sobre o conteúdo: os octetos são realmente UTF-8, mas etiquetá-los como a codepage do host é o que impede a RTL de os converter a caminho do pacote
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 UTF8Encode com um argumento string. No FPC essa chamada não faz nada e é a linha de maior rendimento para auditar
  • Trate todas as variáveis UTF8String como suspeitas em modo Delphi. Aí são simples AnsiString e atribuir-lhes bytes codificados volta a recodificá-los
  • Execute pelo menos uma regressão sob SetMultiByteConversionCodePage com uma codepage de um byte. Um harness de testes LCL corre com CP_UTF8 e 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