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

O pipeline de metadata do PDFium Delphi Component em que TPdf.GetTitle devolve WString UTF-16 em ambos os compilers, a string do Delphi segue UnicodeString enquanto o Free Pascal estreita via DefaultSystemCodePage, de modo que um programa LCL produz UTF-8 válido enquanto um build de console simples dobra o título XMP em bytes únicos inválidos
Uma linha de fonte 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 console estreita metadata para UTF-8 inválido
// 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

Três armadilhas silenciosas do Free Pascal que derrotam um conserto ingênuo com UTF8Encode no encoder de XMP do PDFium Delphi Component, em que um temporário UTF8String retranscodifica os octets no mode Delphi, um argumento AnsiString torna a chamada um no-op, e a concatenação unifica cada operand para a codepage de destino
Cada armadilha falha em silêncio no FPC 3.2.2 em mode Delphi; por isso um encoder correto ainda consegue 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 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

O conserto StringToUtf8 para o PDFium Delphi Component no Free Pascal, em que UTF8Encode roda depois de alargar para UnicodeString e SetCodePage com Convert False reetiqueta o buffer sem mover um byte, de modo que os octets UTF-8 sobrevivem à concatenação para o buffer XMP etiquetado como ANSI em vez de dobrar para bytes únicos inválidos
Passar False no parâmetro Convert conta uma mentira deliberada sobre o conteúdo: os octets são mesmo UTF-8, mas etiquetá-los com a codepage do host é o que impede a RTL de convertê-los a caminho do pacote
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