Artigo Técnico

Codepage do FPC quebra XMP PDF/A no Delphi

O PDFium Delphi Component monta o pacote XMP da saída PDF/A concatenando fragmentos UTF-8 em uma AnsiString, e no Free Pascal 3.2.2 esse pacote silenciosamente deixou de ser UTF-8 válido no instante em que o título de um documento carregou um caractere não ASCII. A ISO 19005-1 6.7.2 exige que o stream de metadados seja UTF-8 válido, portanto o arquivo falhava na validação. A versão 3.103.1 corrige o próprio encoder, em StringToUtf8. A parte interessante não é o patch. É que uma única linha de origem inalterada produzia bytes corretos no Delphi, bytes corretos em uma aplicação Lazarus LCL e bytes corrompidos em um programa de console Free Pascal compilado a partir da mesma unit. Três comportamentos separados de strings do Free Pascal precisam se alinhar antes que isso faça sentido, e cada um deles é defensável isoladamente

Por que o mesmo código de metadados emite bytes diferentes no Delphi e no 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 é declarado como string, então Title, Author, Subject, Keywords, Creator e Producer carregam code units UTF-16 em um compilador e caracteres de um byte mais uma etiqueta de codepage no outro. Mesmo record, mesmo campo, payload diferente. Os próprios valores chegam do documento como UTF-16. TPdf.GetTitle e seus irmãos retornam 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 de narrowing no Free Pascal, e a RTL a executa pela codepage da string de destino. Em um programa LCL, LazUTF8 já definiu DefaultSystemCodePage como CP_UTF8, então o narrowing produz UTF-8 e tudo depois acaba correto. Em um programa de console simples, o mesmo narrowing chega à codepage ANSI, e StringToUtf8 então copia esses octetos sem alteração porque presume que já são UTF-8. Seis bridges de save compartilham esse formato: SaveAsPdfAToStream, SaveAsPdfUaToStream, SaveAsPdfEToStream, SaveAsPdfXToStream, SaveAsPdfRToStream e SaveAsPdfVTToStream, cada um com seu próprio option record

// PDFium.pas: os accessors do documento sao sempre UTF-16
//   WString = WideString no FPC, = string (UnicodeString) no Delphi
function TPdf.GetTitle: WString;

// FPdfPdfa.pas: o record de opcoes de save carrega 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 dicionario Info
// O narrowing agora esta escrito em vez de ficar implicito:
if Eff.Title = '' then
  Eff.Title := WStringToStr(GetTitle);

Encaminhar as seis bridges por um único helper WStringToStr não muda o que a RTL faz, mas coloca a conversão onde um leitor pode vê-la e eliminou 92 warnings de conversão implícita que estavam mascarando exatamente essa classe de problema. É o espelho da corrupção do lado Delphi descrita em nossas notas sobre armadilhas de cross-compiler Delphi e FPC em builds PDFium, em que uma concatenação no Delphi destrói um high byte que o Free Pascal preserva

Três comportamentos do Free Pascal que derrotam a correção óbvia

A correção óbvia é chamar UTF8Encode e terminar. 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 *variavel* UTF8String e uma AnsiString simples,
  // entao a atribuicao transcodifica os octetos de volta para a codepage do host
  U := UTF8Encode(W);

  // Armadilha 2: S ja e uma AnsiString, entao UTF8Encode nao faz nada
  R := UTF8Encode(S);                 // sem decode, sem encode, sem erro
  R := UTF8Encode(UnicodeString(S));  // este realmente codifica

  // Armadilha 3: a concatenacao unifica todo operando para a codepage de destino,
  // e um destino RawByteString nao e excecao
  Xmp := Xmp + R;
end;

A armadilha um significa que o resultado codificado precisa continuar na AnsiString ou RawByteString em que foi produzido. Passe-o por um temporary UTF8String na saída e você terá desfeito o trabalho. A armadilha dois é a que se esconde por mais tempo, porque UTF8Encode(S) compila, executa, retorna um valor do tamanho correto e não faz conversão alguma quando seu argumento já é uma AnsiString; somente ampliar para UnicodeString antes faz a chamada decodificar algo. A armadilha três explica como um encoder correto ainda pode produzir um documento quebrado: BuildXmpBytes acumula o pacote em um Xmp: AnsiString local, e o Free Pascal converte todo operando de uma concatenação para a codepage da variável de destino, reduzindo as sequências multibyte novamente a bytes ANSI simples no caminho de entrada

O que SetCodePage com False realmente garante?

SetCodePage(RawByteString(Result), DefaultSystemCodePage, False) apenas rotula a string, sem tocar em nenhum byte. O terceiro parâmetro é Convert; passar False significa "presuma que o payload já está na codepage de destino e apenas altere a etiqueta". Isso é uma mentira sobre o conteúdo, contada deliberadamente: os octetos são realmente UTF-8, mas marcá-los como a codepage do host é o que impede que a concatenação da armadilha três os converta. Eles entram no buffer XMP como bytes brutos e saem do outro lado sem alteração

function StringToUtf8(const S: string): AnsiString;
begin
{$IFDEF UNICODE}
  // Delphi: UTF8Encode ja produz octetos marcados CP_UTF8, e a
  // concatenacao em uma AnsiString os preserva
  Result := AnsiString(UTF8Encode(S));
{$ELSE}
  // FPC: amplie primeiro, ou UTF8Encode nao-opera em um argumento AnsiString
  Result := UTF8Encode(UnicodeString(S));
  // Rotule sem transcodificar, para que os octetos sobrevivam a concatenacao
  // nos buffers marcados ANSI que montam pacotes XMP e objetos de string PDF
  if Length(Result) > 0 then
    SetCodePage(RawByteString(Result), DefaultSystemCodePage, False);
{$ENDIF}
end;

Seja claro sobre o limite. O retag é exclusivo do FPC e não é uma licença geral para misturar strings marcadas e não marcadas. Ele funciona aqui porque existe exatamente um padrão de consumo depois: anexar a uma AnsiString e escrever o buffer como bytes. Qualquer coisa que tentasse interpretar o valor retagged como texto na codepage do host leria mojibake, corretamente. A direção inversa é tratada do outro modo e é idêntica nos dois compiladores: marque o buffer recebido como CP_UTF8 com SetCodePage(..., False) e então chame UTF8ToString

Por que os testes de regressão carregavam a mesma armadilha?

Porque um teste que constrói seus bytes esperados a partir de um literal de origem está testando o compilador, não a biblioteca. Uma constante como #$C3#$A9 escrita em um arquivo-fonte Pascal carrega a codepage de compilação desse arquivo, e quando é passada a um parâmetro AnsiString a RTL a recodifica, que é justamente a conversão sob teste. A expectativa precisa ser montada em runtime, byte a byte, e comparada byte a byte, porque = em dois valores AnsiString com etiquetas diferentes reconcilia as codepages antes de comparar e retorna um 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;                    // o narrowing sob teste
    Encoded := StringToUtf8(Narrowed);
  finally
    SetMultiByteConversionCodePage(Saved);
  end;
  // 'Caf' + U+00E9 como UTF-8, construido em runtime para nenhum literal ser 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 em si é um programa LCL, então DefaultSystemCodePage é CP_UTF8 e o bug fica invisível até o teste alterá-lo. SetMultiByteConversionCodePage(1252) dentro de um try..finally reproduz o ambiente de console simples durante um teste. A checagem end-to-end vai além e afirma as duas direções: o pacote XMP produzido pela injeção de marcadores precisa conter $43 $61 $66 $C3 $A9 e não conter $43 $61 $66 $E9, para que uma regressão futura que volte à saída bruta de um byte falhe de forma ruidosa, em vez de produzir um arquivo que apenas pareça plausível em um hex dump. Se você trabalha com metadados não latinos, a mesma disciplina de widening rege os casos em texto emoji e CJK que quebra o tratamento WideChar no Delphi

Onde mais o narrowing aparece

XMP é a vítima visível, mas qualquer bridge de TBytes para string na mesma codebase tinha a mesma exposição. Mais dois foram corrigidos na v3.103.1: Utf8BytesToString e StringToUtf8Bytes em FPdfProduction, que fazem round-trip do pacote de datasets XFA por uma string para que MergePdfXfaDatasets possa substituir valores vinculados, e BytesToUtf8 em FPdfTrustedList, que decodifica XML de trusted list europeu depois de remover a marca de ordem de bytes. Ambos agora armazenam o buffer em uma RawByteString, marcam-no como CP_UTF8 sem converter e decodificam com UTF8ToString. Um módulo já era imune, e vale copiar o motivo. O writer XFDF declara seu próprio tipo de texto como XFDFString, resolvido como WideString no FPC e UnicodeString no Delphi, então seu encoder nunca vê uma AnsiString marcada por codepage. Essa é a correção estrutural: mantenha o texto em um tipo UTF-16 até o ponto exato de serialização e deixe uma única função estreita ser dona da conversão para bytes. Todo bug desta família veio de um campo string no meio de um pipeline que era UTF-16 em uma ponta e octetos na outra

O que verificar no seu próprio código PDF para dois compiladores

Se você distribui Object Pascal que roda nos dois compiladores e grava metadados em um PDF conforme os padrões, quatro verificações encontram a maioria dos casos desta classe antes do validator

  • Procure UTF8Encode com argumento string. No FPC essa chamada é no-op, e é a linha de maior retorno para auditar
  • Trate toda variável UTF8String em modo Delphi como suspeita. Ali ela é uma AnsiString simples, e atribuir bytes codificados a ela os transcodifica de volta
  • Execute pelo menos uma regressão sob SetMultiByteConversionCodePage com uma codepage de um byte. Um harness LCL roda em CP_UTF8 e nunca reproduzirá um programa de console simples
  • Monte vetores de bytes esperados em runtime e compare-os octeto por octeto. Literais de origem e = passam pela reconciliação de codepage e esconderão o defeito que você está procurando

Nada disso é uma curiosidade exótica do Free Pascal. É o custo comum de uma linguagem que manteve um tipo de string orientado a bytes ao lado de outro UTF-16, e os dois compiladores fizeram escolhas razoáveis, mas diferentes, sobre o significado de string. A consequência prática para PDF é estreita e aguda: metadados que parecem corretos na sua IDE podem chegar ao pacote XMP como UTF-8 inválido, e a ISO 19005-1 6.7.2 não se importa com qual compilador os colocou lá. Se você está construindo um pipeline de arquivamento, a camada de codificação merece tanta atenção quanto o restante do fluxo de conformidade de arquivamento PDF/A que a envolve. O PDFium Delphi Component distribui essas conversões como parte da biblioteca, então SaveAsPdfA e suas cinco variantes de padrões emitem metadados UTF-8 conformes no Delphi, no Lazarus e em builds Free Pascal simples, sem configuração de codepage por parte do chamador. A documentação completa da API e a release atual estão na página do produto PDFium Delphi Component