Articolo tecnico

Le codepage FPC rompono i metadati XMP PDF/A in Delphi

Il PDFium Delphi Component assembla il pacchetto XMP per l'output PDF/A concatenando frammenti UTF-8 in una AnsiString, e su Free Pascal 3.2.2 quel pacchetto smette silenziosamente di essere UTF-8 valido appena il titolo del documento contiene un carattere non ASCII. ISO 19005-1 6.7.2 richiede che lo stream dei metadati sia UTF-8 valido, quindi il file fallisce la validazione. La versione 3.103.1 corregge l'encoder stesso, in StringToUtf8. La parte interessante non è la patch. È che una riga sorgente immutata produceva byte corretti sotto Delphi, byte corretti in un'applicazione Lazarus LCL e byte corrotti in un programma console Free Pascal compilato dalla stessa unità. Prima che tutto questo abbia senso devono allinearsi tre comportamenti distinti delle stringhe Free Pascal, e ciascuno è difendibile da solo

Perché lo stesso codice dei metadati produce byte diversi in Delphi e FPC?

Perché string non è lo stesso tipo nei due compilatori. FPC 3.2.2 in {$MODE Delphi} compila string come una AnsiString marcata con DefaultSystemCodePage, mentre Delphi la compila come UnicodeString. Ogni campo metadati in TPdfASaveOptions è dichiarato string, quindi Title, Author, Subject, Keywords, Creator e Producer contengono UTF-16 code unit su un compilatore e caratteri a un byte più un'etichetta codepage sull'altro. Stesso record, stesso campo, payload diverso. I valori arrivano dal documento in UTF-16. TPdf.GetTitle e i suoi fratelli restituiscono WString, che è WideString su FPC e string (UnicodeString) su Delphi, e SaveAsPdfAToStream riempie ogni campo option vuoto dal dizionario Info prima di iniettare i marker. Su Free Pascal quell'assegnazione è una conversione narrowing e la RTL la esegue attraverso la codepage della stringa target. In un programma LCL LazUTF8 ha già impostato DefaultSystemCodePage a CP_UTF8, quindi il narrowing produce UTF-8 e tutto ciò che segue risulta casualmente corretto. In un programma console semplice lo stesso narrowing finisce sulla codepage ANSI e StringToUtf8 copia poi quegli ottetti senza modificarli perché presumeva fossero già UTF-8. Sei save bridge condividono questa forma: SaveAsPdfAToStream, SaveAsPdfUaToStream, SaveAsPdfEToStream, SaveAsPdfXToStream, SaveAsPdfRToStream e SaveAsPdfVTToStream, ciascuno con il proprio option record

// PDFium.pas: gli accessor del documento sono sempre UTF-16
//   WString = WideString su FPC, = string (UnicodeString) su Delphi
function TPdf.GetTitle: WString;

// FPdfPdfa.pas: il record delle opzioni di salvataggio porta metadati come `string`
TPdfASaveOptions = record
  Conformance: TPdfAConformance;
  IccProfileData: TBytes;
  Title: string;      // UnicodeString su Delphi
                      // AnsiString + DefaultSystemCodePage su 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 riempie i campi vuoti dal dizionario Info.
// Il narrowing è ora esplicitato invece di essere lasciato implicito:
if Eff.Title = '' then
  Eff.Title := WStringToStr(GetTitle);

Instradare tutti e sei i bridge attraverso un unico helper WStringToStr non cambia ciò che fa la RTL, ma mette la conversione dove un lettore può vederla e ha eliminato 92 warning di conversione implicita che nascondevano esattamente questa classe di problema. È l'immagine speculare della corruzione lato Delphi descritta nelle note sui problemi cross-compiler Delphi e FPC nelle build PDFium, dove una concatenazione in Delphi distrugge un high byte conservato da Free Pascal

Tre comportamenti Free Pascal che sconfiggono la correzione ovvia

La correzione ovvia è chiamare UTF8Encode e chiuderla lì. Su FPC 3.2.2 in mode Delphi fallisce tre volte, e ogni volta in silenzio

var
  W: UnicodeString;
  S: string;
  U: UTF8String;
  R: RawByteString;
  Xmp: AnsiString;
begin
  // Tranello 1: in mode Delphi una variabile UTF8String è una semplice AnsiString,
  // quindi l'assegnazione ritrascodifica gli ottetti nella codepage dell'host
  U := UTF8Encode(W);

  // Tranello 2: S è già una AnsiString, quindi UTF8Encode non fa assolutamente nulla
  R := UTF8Encode(S);                 // nessun decode, nessun encode, nessun errore
  R := UTF8Encode(UnicodeString(S));  // questo esegue davvero l'encode

  // Tranello 3: la concatenazione unifica ogni operand nella codepage di destinazione,
  // e una destinazione RawByteString non fa eccezione
  Xmp := Xmp + R;
end;

Il primo tranello significa che il risultato codificato deve restare nella AnsiString o RawByteString in cui è stato prodotto. Passatelo attraverso una temporary UTF8String in uscita e avrete annullato il lavoro. Il secondo è quello che si nasconde più a lungo, perché UTF8Encode(S) compila, gira, restituisce un valore della lunghezza corretta e non esegue alcuna conversione quando l'argomento è già una AnsiString; solo l'allargamento preventivo a UnicodeString fa sì che la chiamata decodifichi qualcosa. Il terzo spiega perché un encoder corretto possa ancora produrre un documento rotto: BuildXmpBytes accumula il pacchetto in una variabile locale Xmp: AnsiString, e Free Pascal converte ogni operand della concatenazione nella codepage della variabile di destinazione, riducendo le sequenze multibyte a byte ANSI singoli durante l'inserimento

Cosa garantisce davvero SetCodePage con False?

SetCodePage(RawByteString(Result), DefaultSystemCodePage, False) rietichetta la stringa senza toccare un byte. Il terzo parametro è Convert; passare False significa "presumere che il payload sia già nella codepage target e cambiare solo il tag". È una bugia sul contenuto, raccontata deliberatamente: gli ottetti sono davvero UTF-8, ma marcarli come codepage dell'host impedisce alla concatenazione del terzo tranello di convertirli. Si uniscono al buffer XMP come byte raw e ne escono invariati

function StringToUtf8(const S: string): AnsiString;
begin
{$IFDEF UNICODE}
  // Delphi: UTF8Encode produce già ottetti marcati CP_UTF8 e la
  // concatenazione in una AnsiString li conserva
  Result := AnsiString(UTF8Encode(S));
{$ELSE}
  // FPC: allargare prima, altrimenti UTF8Encode è un no-op su un argomento AnsiString
  Result := UTF8Encode(UnicodeString(S));
  // Rietichettare senza transcoding, così gli ottetti sopravvivono alla concatenazione
  // nei buffer con tag ANSI che assemblano pacchetti XMP e oggetti stringa PDF
  if Length(Result) > 0 then
    SetCodePage(RawByteString(Result), DefaultSystemCodePage, False);
{$ENDIF}
end;

È importante chiarire il confine. Il retag è solo FPC e non è una licenza generale per mescolare stringhe taggate e non taggate. Funziona qui perché a valle esiste esattamente un pattern consumer: append a una AnsiString, poi scrivere il buffer come byte. Qualsiasi cosa provasse a interpretare il valore ritaggato nella codepage dell'host leggerebbe mojibake, correttamente. La direzione inversa viene gestita nel modo opposto ed è identica su entrambi i compilatori: marcare il buffer in ingresso come CP_UTF8 con SetCodePage(..., False), poi chiamare UTF8ToString

Perché anche i test di regressione contenevano lo stesso tranello?

Perché un test che costruisce i byte attesi da un literal sorgente sta testando il compilatore, non la libreria. Una costante come #$C3#$A9 scritta in un file Pascal porta la codepage di compilazione di quel file e, quando viene passata a un parametro AnsiString, la RTL la ricodifica, cioè esegue proprio la conversione sotto test. L'aspettativa deve essere assemblata a runtime, byte per byte, e confrontata byte per byte, perché = su due valori AnsiString con tag diversi riconcilia le codepage prima del confronto e restituisce un allegro 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;                    // il narrowing sotto test
    Encoded := StringToUtf8(Narrowed);
  finally
    SetMultiByteConversionCodePage(Saved);
  end;
  // 'Caf' + U+00E9 come UTF-8, costruito a runtime per evitare la ricodifica dei literal
  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;

L'harness è un programma LCL, quindi DefaultSystemCodePage è CP_UTF8 e il bug è invisibile finché il test non lo cambia. SetMultiByteConversionCodePage(1252) dentro un try..finally riproduce l'ambiente console semplice per la durata di un test. Il controllo end-to-end va oltre e verifica entrambe le direzioni: il pacchetto XMP prodotto dall'iniezione dei marker deve contenere $43 $61 $66 $C3 $A9 e non deve contenere $43 $61 $66 $E9, così una regressione futura che torni all'output raw a byte singolo fallisce in modo evidente invece di produrre un file solo apparentemente plausibile in un hex dump. Se lavorate con metadati non latini, la stessa disciplina di widening governa i casi in emoji e testo CJK che rompono WideChar handling in Delphi

Dove finisce altrimenti il narrowing

XMP è la vittima visibile, ma qualsiasi bridge TBytes verso string nello stesso codebase aveva la stessa esposizione. Altri due sono stati corretti nella v3.103.1: Utf8BytesToString e StringToUtf8Bytes in FPdfProduction, che fanno round-trip del packet XFA datasets attraverso una stringa perché MergePdfXfaDatasets possa sostituire i valori bindati, e BytesToUtf8 in FPdfTrustedList, che decodifica l'XML delle trusted list europee dopo aver rimosso il byte-order mark. Ora entrambi preparano il buffer in una RawByteString, lo marcano CP_UTF8 senza convertire e lo decodificano con UTF8ToString. Un modulo era già immune, e vale la pena copiarne il motivo. Il writer XFDF dichiara il proprio tipo di testo come XFDFString, che si risolve in WideString sotto FPC e UnicodeString sotto Delphi, quindi il suo encoder non vede mai una AnsiString con codepage nel mezzo. Questa è la correzione strutturale: mantenere il testo in un tipo UTF-16 fino al punto preciso della serializzazione e lasciare che una sola funzione narrow possieda la conversione a byte. Ogni bug di questa famiglia nasceva da un campo string collocato al centro di una pipeline che era UTF-16 a un'estremità e ottetti all'altra

Cosa controllare nel vostro codice PDF a due compilatori

Se distribuite Object Pascal che gira su entrambi i compilatori e scrive metadati in un PDF conforme agli standard, quattro controlli intercettano la maggior parte di questa classe prima del validator

  • Cercate UTF8Encode con un argomento string. Su FPC quella chiamata è un no-op ed è la riga con il rendimento più alto da verificare
  • Trattate ogni variabile UTF8String come sospetta in mode Delphi. È una semplice AnsiString e assegnarle byte codificati li ritrascodifica
  • Eseguite almeno una regressione sotto SetMultiByteConversionCodePage con una codepage a byte singolo. Un test harness LCL gira a CP_UTF8 e non riprodurrà mai un programma console semplice
  • Costruite i vettori di byte attesi a runtime e confrontateli ottetto per ottetto. Sia i literal sorgente sia = passano dalla riconciliazione delle codepage e nasconderanno il difetto che state cercando

Non è una stranezza esotica di Free Pascal. È il costo ordinario di un linguaggio che ha mantenuto un tipo di stringa orientato ai byte accanto a uno UTF-16, e i due compilatori hanno fatto scelte ragionevoli ma diverse sul significato di string. La conseguenza pratica per il lavoro PDF è stretta e netta: metadati che nell'IDE appaiono corretti possono raggiungere il pacchetto XMP come UTF-8 non valido, e ISO 19005-1 6.7.2 non si interessa di quale compilatore li abbia inseriti. Se state costruendo una pipeline di archiviazione, il layer di encoding merita la stessa attenzione del resto del workflow di conformità PDF/A che lo circonda. Il PDFium Delphi Component include queste conversioni nella libreria, quindi SaveAsPdfA e i suoi cinque fratelli standard emettono metadati UTF-8 conformi su Delphi, Lazarus e build Free Pascal semplici senza configurazione codepage da parte del caller. La documentazione API completa e la release attuale sono nella pagina prodotto PDFium Delphi Component