Articol tehnic

Capcanele codepage FPC rup metadata XMP PDF/A în Delphi

Componenta PDFium pentru Delphi asamblează pachetul XMP pentru output PDF/A concatenând fragmente UTF-8 într-un AnsiString, iar pe Free Pascal 3.2.2 acel pachet a încetat în tăcere să fie UTF-8 valid în clipa în care titlul unui document a primit un caracter non-ASCII. ISO 19005-1 6.7.2 cere ca metadata stream-ul să fie UTF-8 valid, așa că fișierul eșua la validare. Versiunea 3.103.1 repară encoder-ul însuși, în StringToUtf8. Partea interesantă nu este patch-ul. Este că aceeași linie de source neschimbată producea bytes corecți sub Delphi, bytes corecți într-o aplicație Lazarus LCL și bytes corupți într-un plain Free Pascal console program compilat din același unit. Trei comportamente distincte ale string-urilor Free Pascal trebuie să se alinieze înainte ca acest lucru să aibă sens și fiecare este defensabil separat

De ce emite același cod de metadata bytes diferiți pe Delphi și FPC?

Deoarece string nu este același type în cele două compilere. FPC 3.2.2 în {$MODE Delphi} compilează string ca AnsiString etichetat cu DefaultSystemCodePage, în timp ce Delphi îl compilează ca UnicodeString. Fiecare metadata field din TPdfASaveOptions este declarat string, așa că Title, Author, Subject, Keywords, Creator și Producer poartă unități de cod UTF-16 pe un compiler și caractere single-byte plus o etichetă de codepage pe celălalt. Același record, același field, payload diferit. Valorile în sine sosesc din document ca UTF-16. TPdf.GetTitle și frații lui returnează WString, care este WideString pe FPC și string pe Delphi, iar SaveAsPdfAToStream completează orice option field gol din Info dictionary înainte să injecteze markers. Assignment-ul este o narrowing conversion pe Free Pascal, iar RTL-ul o face prin target string codepage. Într-un program LCL, LazUTF8 a setat deja DefaultSystemCodePage la CP_UTF8, așa că narrowing-ul produce UTF-8 și tot ce urmează se nimerește să fie corect. Într-un plain console program, același narrowing ajunge pe ANSI codepage, iar StringToUtf8 copiază apoi acei octets neschimbați, deoarece presupunea că erau deja UTF-8. Șase save bridges au această formă: SaveAsPdfAToStream, SaveAsPdfUaToStream, SaveAsPdfEToStream, SaveAsPdfXToStream, SaveAsPdfRToStream și SaveAsPdfVTToStream, fiecare cu propriul option record

// PDFium.pas: accessor-ii documentului sunt mereu UTF-16
//   WString = WideString pe FPC, = string (UnicodeString) pe Delphi
function TPdf.GetTitle: WString;

// FPdfPdfa.pas: save option record poartă metadata ca `string`
TPdfASaveOptions = record
  Conformance: TPdfAConformance;
  IccProfileData: TBytes;
  Title: string;      // UnicodeString pe Delphi
                      // AnsiString + DefaultSystemCodePage pe 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 completează fields goale din Info dictionary.
// Narrowing-ul este acum scris explicit, nu lăsat implicit:
if Eff.Title = '' then
  Eff.Title := WStringToStr(GetTitle);

Dirijarea tuturor celor șase bridges printr-un singur helper WStringToStr nu schimbă ce face RTL-ul, dar pune conversia într-un loc în care cititorul o poate vedea și a eliminat 92 de warnings de implicit conversion care mascau exact această clasă de problemă. Este imaginea în oglindă a coruperii pe partea Delphi descrisă în notițele despre capcanele cross-compiler Delphi și FPC în build-urile PDFium, unde o concatenare pe Delphi distruge un high byte pe care Free Pascal îl păstrează

Trei comportamente Free Pascal care înving fix-ul evident

Fix-ul evident este să apelezi UTF8Encode și să termini. Pe FPC 3.2.2 în mode Delphi, acesta eșuează de trei ori, iar fiecare eșec este tăcut

var
  W: UnicodeString;
  S: string;
  U: UTF8String;
  R: RawByteString;
  Xmp: AnsiString;
begin
  // Capcana 1: în mode Delphi o variabilă UTF8String este un AnsiString simplu,
  // așa că assignment-ul transcodează octeții direct înapoi la host codepage
  U := UTF8Encode(W);

  // Capcana 2: S este deja un AnsiString, așa că UTF8Encode nu face nimic
  R := UTF8Encode(S);                 // nici decode, nici encode, nicio eroare
  R := UTF8Encode(UnicodeString(S));  // acesta chiar encodează

  // Capcana 3: concatenarea unifică fiecare operand la destination codepage,
  // iar un destination RawByteString nu este o excepție
  Xmp := Xmp + R;
end;

Capcana unu înseamnă că rezultatul encoded trebuie să rămână în AnsiString sau RawByteString în care a fost produs. Trece-l printr-un temporary UTF8String la ieșire și ai anulat munca. Capcana doi este cea care se ascunde cel mai mult, pentru că UTF8Encode(S) compilează, rulează, returnează o valoare cu lungimea corectă și nu face absolut nicio conversie când argumentul este deja un AnsiString; doar lărgirea la UnicodeString înainte face apelul să decodeze ceva. Capcana trei explică de ce un encoder corect poate produce totuși un document stricat: BuildXmpBytes acumulează packet-ul într-un local Xmp: AnsiString, iar Free Pascal convertește fiecare operand al unei concatenări la codepage-ul variabilei destinație, plierea secvențelor multi-byte înapoi la bytes ANSI single-byte pe drum

Ce garantează de fapt SetCodePage cu False?

SetCodePage(RawByteString(Result), DefaultSystemCodePage, False) relabels string-ul fără să atingă vreun byte. Al treilea parameter este Convert; transmiterea lui False înseamnă „presupune că payload-ul este deja în target codepage și schimbă doar tag-ul”. Este o minciună despre content, spusă deliberat: octeții sunt în realitate UTF-8, dar etichetarea lor ca host codepage este ceea ce oprește concatenarea din capcana trei să îi convertească. Ei intră în XMP buffer ca raw bytes și ies din partea cealaltă neschimbați

function StringToUtf8(const S: string): AnsiString;
begin
{$IFDEF UNICODE}
  // Delphi: UTF8Encode produce deja octeți marcați CP_UTF8, iar
  // concatenarea într-un AnsiString îi păstrează
  Result := AnsiString(UTF8Encode(S));
{$ELSE}
  // FPC: lărgește mai întâi, altfel UTF8Encode nu face nimic pe un AnsiString
  Result := UTF8Encode(UnicodeString(S));
  // Relabelează fără transcodare, pentru ca octeții să supraviețuiască concatenării
  // în bufferele cu tag ANSI care asamblează packet-uri XMP și PDF string objects
  if Length(Result) > 0 then
    SetCodePage(RawByteString(Result), DefaultSystemCodePage, False);
{$ENDIF}
end;

Fii clar cu limita. Retagging-ul este FPC-only și nu este o binecuvântare generală pentru amestecarea string-urilor tagged și untagged. Funcționează aici deoarece există exact un consumer pattern downstream: append la un AnsiString, apoi scrierea buffer-ului ca bytes. Orice ar încerca să interpreteze valoarea retagged ca text în host codepage ar citi mojibake și corect ar fi. Direcția inversă este tratată altfel și identic pe ambele compilere: marchează buffer-ul incoming cu CP_UTF8 prin SetCodePage(..., False), apoi apelează UTF8ToString

De ce purtau și regression tests aceeași capcană?

Deoarece un test care își construiește expected bytes dintr-un source literal testează compiler-ul, nu biblioteca. O constantă precum #$C3#$A9 dintr-un source file Pascal poartă codepage-ul de compilare al acelui file, iar când este transmisă unui parameter AnsiString, RTL-ul o re-encodează, exact conversia aflată sub test. Expected-ul trebuie asamblat la runtime, byte cu byte, și comparat byte cu byte, deoarece = pe două values AnsiString cu tags diferite reconciliază codepage-urile înainte de comparație și returnează un false negative vesel

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;                    // narrowing-ul aflat sub test
    Encoded := StringToUtf8(Narrowed);
  finally
    SetMultiByteConversionCodePage(Saved);
  end;
  // 'Caf' + U+00E9 ca UTF-8, construit la runtime pentru ca niciun literal să nu fie re-encodat
  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;

Harness-ul este el însuși un program LCL, așa că DefaultSystemCodePage este CP_UTF8, iar bug-ul este invizibil până când testul îl schimbă. SetMultiByteConversionCodePage(1252) într-un try..finally reproduce mediul plain console pe durata unui test. Check-ul end-to-end merge mai departe și verifică ambele direcții: packet-ul XMP produs de marker injection trebuie să conțină $43 $61 $66 $C3 $A9 și să nu conțină $43 $61 $66 $E9, astfel încât un regression viitor care revine la output raw single-byte să eșueze zgomotos, nu să producă un fișier doar plauzibil într-un hex dump. Dacă lucrezi cu metadata non-Latin, aceeași disciplină de widening guvernează și cazurile din emoji și text CJK care rup tratarea WideChar în Delphi

Unde mai ajunge narrowing-ul

XMP este victima vizibilă, dar orice TBytes-to-string bridge din același codebase avea aceeași expunere. Încă două au fost corectate în v3.103.1: Utf8BytesToString și StringToUtf8Bytes din FPdfProduction, care fac round-trip packet-ului XFA datasets printr-un string pentru ca MergePdfXfaDatasets să poată substitui valori bound, și BytesToUtf8 din FPdfTrustedList, care decodează XML-ul european trusted-list după ce elimină byte-order mark-ul. Ambele pregătesc acum buffer-ul într-un RawByteString, îl marchează CP_UTF8 fără conversie și decodează cu UTF8ToString. Un modul era deja imun, iar motivul merită copiat. XFDF writer-ul își declară propriul text type ca XFDFString, care devine WideString sub FPC și UnicodeString sub Delphi, așa că encoder-ul nu întâlnește niciodată un AnsiString cu codepage tag. Acesta este fix-ul structural: păstrează textul într-un type UTF-16 până la punctul exact al serializării și lasă o singură funcție îngustă să dețină conversia în bytes. Fiecare bug din familia aceasta a pornit de la un field string aflat în mijlocul unui pipeline care era altfel UTF-16 la un capăt și octeți la celălalt

Ce să verifici în propriul cod PDF cu doi compilere

Dacă livrezi Object Pascal care rulează pe ambele compilere și scrie metadata într-un PDF conform cu standardele, patru checks găsesc majoritatea acestei clase înaintea unui validator

  • Caută UTF8Encode cu argument string. Pe FPC acel apel este no-op și este linia cu cel mai mare randament de auditat
  • Tratează fiecare variabilă UTF8String ca suspectă în mode Delphi. Acolo este un simplu AnsiString, iar assignment-ul bytes-ilor encoded către ea îi transcodează înapoi
  • Rulează cel puțin un regression sub SetMultiByteConversionCodePage cu un single-byte codepage. Un LCL test harness rulează la CP_UTF8 și nu va reproduce niciodată un plain console program
  • Construiește byte vectors expected la runtime și compară-i octet cu octet. Source literals și = trec ambele prin codepage reconciliation și vor ascunde defectul pe care îl cauți

Nimic din toate acestea nu este trivia exotică Free Pascal. Este costul obișnuit al unui limbaj care a păstrat un string type orientat pe bytes alături de unul UTF-16, iar cele două compilere au făcut alegeri rezonabile, dar diferite, despre ce ar trebui să însemne string. Consecința practică pentru PDF este îngustă și ascuțită: metadata care arată bine în IDE poate ajunge în XMP packet ca UTF-8 invalid, iar ISO 19005-1 6.7.2 nu este interesat de compiler-ul care a pus-o acolo. Dacă construiești un archival pipeline, layer-ul de encoding merită tot atâta atenție ca restul workflow-ului de conformitate PDF/A din jur. Componenta PDFium pentru Delphi livrează aceste conversii ca parte a bibliotecii, așa că SaveAsPdfA și cei cinci siblings pentru standards emit metadata UTF-8 conformă pe Delphi, Lazarus și plain Free Pascal fără nicio configurare de codepage din partea caller-ului. Documentația API completă și release-ul curent sunt pe pagina de produs PDFium Delphi Component