Τεχνικό Άρθρο

FPC codepage traps που χαλούν PDF/A XMP metadata στο Delphi

Το PDFium Delphi Component συναρμολογεί το XMP packet για PDF/A output ενώνοντας UTF-8 fragments σε AnsiString, και στο Free Pascal 3.2.2 αυτό το packet έπαυε σιωπηρά να είναι valid UTF-8 μόλις ο τίτλος του document περιείχε non-ASCII χαρακτήρα. Το ISO 19005-1 6.7.2 απαιτεί το metadata stream να είναι valid UTF-8 και έτσι το file αποτύγχανε στο validation. Η version 3.103.1 διορθώνει τον ίδιο τον encoder, στο StringToUtf8. Το ενδιαφέρον δεν είναι το patch. Είναι ότι μία unchanged source line παρήγαγε σωστά bytes στο Delphi, σωστά bytes σε Lazarus LCL application και corrupt bytes σε plain Free Pascal console program που είχε χτιστεί από την ίδια unit. Τρεις διαφορετικές συμπεριφορές string του Free Pascal πρέπει να ευθυγραμμιστούν πριν βγάλει νόημα αυτό, και καθεμία είναι υπερασπίσιμη μόνη της

Γιατί ο ίδιος metadata code εκπέμπει διαφορετικά bytes σε Delphi και FPC

Επειδή το string δεν είναι ο ίδιος type στους δύο compilers. Το FPC 3.2.2 σε {$MODE Delphi} μεταγλωττίζει το string σε AnsiString με tag DefaultSystemCodePage, ενώ το Delphi το μεταγλωττίζει σε UnicodeString. Κάθε metadata field στο TPdfASaveOptions δηλώνεται ως string, άρα τα Title, Author, Subject, Keywords, Creator και Producer μεταφέρουν UTF-16 code units στον έναν compiler και single-byte characters συν codepage label στον άλλο. Ίδιο record, διαφορετικό payload. Οι τιμές φτάνουν από το document ως UTF-16. Τα TPdf.GetTitle και τα siblings του επιστρέφουν WString, που είναι WideString στο FPC και string (UnicodeString) στο Delphi, ενώ το SaveAsPdfAToStream γεμίζει κάθε κενό option field από το Info dictionary πριν εισάγει markers. Αυτή η assignment είναι narrowing conversion στο Free Pascal και το RTL την κάνει μέσω του target string codepage. Σε LCL program το LazUTF8 έχει ήδη ορίσει το DefaultSystemCodePage σε CP_UTF8, οπότε το narrowing παράγει UTF-8 και ό,τι ακολουθεί τυχαίνει να είναι σωστό. Σε plain console program το ίδιο narrowing καταλήγει στο ANSI codepage, και μετά το StringToUtf8 αντέγραψε αυτά τα octets ανέγγιχτα επειδή υπέθεσε ότι ήταν ήδη UTF-8. Έξι save bridges έχουν αυτό το σχήμα: SaveAsPdfAToStream, SaveAsPdfUaToStream, SaveAsPdfEToStream, SaveAsPdfXToStream, SaveAsPdfRToStream και SaveAsPdfVTToStream, καθένα με δικό του option record

// PDFium.pas: τα document accessors είναι πάντα UTF-16
//   WString = WideString στο FPC, = string (UnicodeString) στο Delphi
function TPdf.GetTitle: WString;

// FPdfPdfa.pas: το save option record κρατά metadata ως `string`
TPdfASaveOptions = record
  Conformance: TPdfAConformance;
  IccProfileData: TBytes;
  Title: string;      // UnicodeString στο Delphi
                      // AnsiString + DefaultSystemCodePage στο 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 συμπληρώνει blank fields από το Info dictionary.
// Το narrowing δηλώνεται πλέον ρητά αντί να μένει implicit:
if Eff.Title = '' then
  Eff.Title := WStringToStr(GetTitle);

Η δρομολόγηση και των έξι bridges μέσω ενός helper WStringToStr δεν αλλάζει αυτό που κάνει το RTL, αλλά βάζει τη conversion εκεί όπου μπορεί να τη δει ο reader και καθάρισε 92 implicit-conversion warnings που έκρυβαν ακριβώς αυτή την κατηγορία προβλήματος. Είναι το mirror image της corruption στην πλευρά του Delphi που περιγράφεται στις σημειώσεις για Delphi και FPC cross-compiler pitfalls σε PDFium builds, όπου μια concatenation στο Delphi καταστρέφει high byte που το Free Pascal διατηρεί

Τρεις συμπεριφορές του Free Pascal που νικούν την προφανή διόρθωση

Η προφανής διόρθωση είναι να καλέσεις UTF8Encode και να τελειώνεις. Αυτό αποτυγχάνει τρεις φορές στο FPC 3.2.2 σε mode Delphi, και κάθε αποτυχία είναι σιωπηλή

var
  W: UnicodeString;
  S: string;
  U: UTF8String;
  R: RawByteString;
  Xmp: AnsiString;
begin
  // Trap 1: σε mode Delphi ένα UTF8String *variable* είναι plain AnsiString,
  // οπότε η assignment κάνει transcode τα octets κατευθείαν πίσω στο host codepage
  U := UTF8Encode(W);

  // Trap 2: το S είναι ήδη AnsiString, άρα το UTF8Encode δεν κάνει τίποτε
  R := UTF8Encode(S);                 // no decode, no encode, no error
  R := UTF8Encode(UnicodeString(S));  // αυτό πράγματι κάνει encode

  // Trap 3: η concatenation ενοποιεί κάθε operand στο destination codepage,
  // και ένα RawByteString destination δεν αποτελεί εξαίρεση
  Xmp := Xmp + R;
end;

Το trap one σημαίνει ότι το encoded result πρέπει να παραμείνει στο AnsiString ή RawByteString από όπου παρήχθη. Πέρασέ το από προσωρινό UTF8String στο δρόμο προς τα έξω και έχεις αναιρέσει τη δουλειά. Το trap two είναι αυτό που κρύβεται περισσότερο, επειδή το UTF8Encode(S) κάνει compile, τρέχει, επιστρέφει value σωστού length και δεν κάνει καθόλου conversion όταν το argument του είναι ήδη AnsiString· μόνο το widening πρώτα σε UnicodeString κάνει τη call να αποκωδικοποιήσει κάτι. Το trap three εξηγεί πώς ένας σωστός encoder μπορεί και πάλι να παράγει broken document: το BuildXmpBytes συσσωρεύει το packet σε local Xmp: AnsiString, και το Free Pascal μετατρέπει κάθε operand μιας concatenation στο codepage της destination variable, αναδιπλώνοντας τις multi-byte sequences πίσω σε single ANSI bytes κατά την είσοδο

Τι εγγυάται πραγματικά το SetCodePage με False

Το SetCodePage(RawByteString(Result), DefaultSystemCodePage, False) ξανασημαίνει το string χωρίς να αγγίξει ούτε byte. Η τρίτη parameter είναι Convert· περνώντας False λες «υπόθεσε ότι το payload είναι ήδη στο target codepage και άλλαξε μόνο το tag». Είναι ψέμα για το content που λέγεται σκόπιμα: τα octets είναι πράγματι UTF-8, αλλά το tagging τους ως host codepage είναι αυτό που σταματά την concatenation του trap three από το να τα μετατρέψει. Ενώνονται στο XMP buffer ως raw bytes και βγαίνουν από την άλλη πλευρά ανέγγιχτα

function StringToUtf8(const S: string): AnsiString;
begin
{$IFDEF UNICODE}
  // Delphi: το UTF8Encode δίνει ήδη octets με CP_UTF8 tag και η
  // concatenation σε AnsiString τα διατηρεί
  Result := AnsiString(UTF8Encode(S));
{$ELSE}
  // FPC: κάνε πρώτα widen, αλλιώς το UTF8Encode είναι no-op σε AnsiString argument
  Result := UTF8Encode(UnicodeString(S));
  // Retag χωρίς transcode, ώστε τα octets να επιβιώσουν σε concatenation προς
  // τα ANSI-tagged buffers που συναρμολογούν XMP packets και PDF string objects
  if Length(Result) > 0 then
    SetCodePage(RawByteString(Result), DefaultSystemCodePage, False);
{$ENDIF}
end;

Χρειάζεται σαφήνεια για το boundary. Το retag είναι FPC-only και δεν αποτελεί γενική ευλογία για mixing tagged και untagged strings. Λειτουργεί εδώ επειδή υπάρχει ακριβώς ένα consumer pattern downstream: append σε AnsiString και μετά write του buffer ως bytes. Οτιδήποτε επιχειρούσε να ερμηνεύσει την retagged value ως text στο host codepage θα διάβαζε mojibake, και σωστά. Η αντίστροφη κατεύθυνση γίνεται με τον άλλο τρόπο και είναι ίδια και στους δύο compilers: κάνε tag το incoming buffer CP_UTF8 με SetCodePage(..., False) και μετά κάλεσε UTF8ToString

Γιατί τα regression tests είχαν την ίδια παγίδα

Επειδή test που χτίζει τα expected bytes από source literal ελέγχει τον compiler και όχι τη library. Constant όπως #$C3#$A9 σε Pascal source file μεταφέρει το compile-time codepage εκείνου του file, και όταν περάσει σε parameter AnsiString το RTL το ξανακωδικοποιεί, που είναι ακριβώς η conversion υπό test. Το expectation πρέπει να συναρμολογηθεί στο runtime, byte προς byte, και να συγκριθεί byte προς byte, επειδή το = σε δύο AnsiString values με διαφορετικά tags συμφιλιώνει τα codepages πριν συγκρίνει και επιστρέφει ένα χαμογελαστό false negative

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 υπό test
    Encoded := StringToUtf8(Narrowed);
  finally
    SetMultiByteConversionCodePage(Saved);
  end;
  // 'Caf' + U+00E9 ως UTF-8, χτισμένο στο runtime ώστε κανένα 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;

Το harness είναι LCL program, άρα το DefaultSystemCodePage είναι CP_UTF8 και το bug μένει αόρατο μέχρι το test να το αλλάξει. Το SetMultiByteConversionCodePage(1252) μέσα σε try..finally αναπαράγει το plain console environment για τη διάρκεια ενός test. Ο end-to-end check πηγαίνει παραπέρα και ελέγχει και τις δύο κατευθύνσεις: το XMP packet που παράγεται από marker injection πρέπει να περιέχει $43 $61 $66 $C3 $A9 και να μην περιέχει $43 $61 $66 $E9, ώστε future regression που επιστρέφει σε raw single-byte output να αποτυγχάνει loud αντί να παράγει file που απλώς μοιάζει plausible σε hex dump. Αν δουλεύεις με non-Latin metadata, η ίδια widening discipline διέπει τις περιπτώσεις στο emoji και CJK text που χαλούν το WideChar handling στο Delphi

Πού αλλού καταλήγει το narrowing

Το XMP είναι η ορατή απώλεια, αλλά κάθε TBytes to string bridge στο ίδιο codebase είχε την ίδια έκθεση. Άλλα δύο διορθώθηκαν στο v3.103.1: τα Utf8BytesToString και StringToUtf8Bytes στο FPdfProduction, που κάνουν round-trip το XFA datasets packet μέσω string ώστε το MergePdfXfaDatasets να αντικαθιστά bound values, και το BytesToUtf8 στο FPdfTrustedList, που αποκωδικοποιεί European trusted-list XML αφού αφαιρέσει το byte-order mark. Και τα δύο κάνουν πλέον stage το buffer σε RawByteString, το κάνουν tag ως CP_UTF8 χωρίς conversion και κάνουν decode με UTF8ToString. Ένα module ήταν ήδη immune, και ο λόγος αξίζει να αντιγραφεί. Ο XFDF writer δηλώνει δικό του text type ως XFDFString, που επιλύεται σε WideString στο FPC και UnicodeString στο Delphi, οπότε ο encoder του δεν βλέπει ποτέ codepage-tagged AnsiString. Αυτή είναι η structural fix: κράτα το text σε UTF-16 type μέχρι ακριβώς το σημείο serialization και άφησε μία narrow function να κατέχει τη conversion σε bytes. Κάθε bug αυτής της οικογένειας προήλθε από string field στη μέση pipeline που ήταν κατά τα άλλα UTF-16 στο ένα άκρο και octets στο άλλο

Τι να ελέγξεις στον δικό σου dual-compiler PDF code

Αν διανέμεις Object Pascal που τρέχει και στους δύο compilers και γράφει metadata σε standards-conformant PDF, τέσσερις έλεγχοι βρίσκουν το μεγαλύτερο μέρος αυτής της κατηγορίας πριν από validator

  • Κάνε grep για UTF8Encode με argument string. Στο FPC εκείνο το call είναι no-op και είναι η γραμμή με το υψηλότερο yield για audit
  • Θεώρησε κάθε UTF8String variable ύποπτη σε mode Delphi. Εκεί είναι plain AnsiString και assignment encoded bytes σε αυτήν τα κάνει transcode προς τα πίσω
  • Τρέξε τουλάχιστον ένα regression κάτω από SetMultiByteConversionCodePage με single-byte codepage. LCL test harness τρέχει σε CP_UTF8 και δεν θα αναπαράγει ποτέ plain console program
  • Χτίσε expected byte vectors στο runtime και σύγκρινέ τα octet προς octet. Source literals και = περνούν και τα δύο από codepage reconciliation και θα κρύψουν το defect που ψάχνεις

Τίποτε από αυτά δεν είναι exotic Free Pascal trivia. Είναι το συνηθισμένο κόστος μιας γλώσσας που κράτησε byte-oriented string type δίπλα σε UTF-16 type, και οι δύο compilers έκαναν λογικές αλλά διαφορετικές επιλογές για το τι πρέπει να σημαίνει το string. Η πρακτική συνέπεια για PDF work είναι στενή και αιχμηρή: metadata που διαβάζεται σωστά στο IDE μπορεί να φτάσει στο XMP packet ως invalid UTF-8, και το ISO 19005-1 6.7.2 δεν ενδιαφέρεται ποιος compiler το έβαλε εκεί. Αν χτίζεις archival pipeline, το encoding layer αξίζει τόση προσοχή όση και η υπόλοιπη PDF/A archival compliance workflow που το περιβάλλει. Το PDFium Delphi Component διαθέτει αυτές τις conversions ως μέρος της library, οπότε το SaveAsPdfA και τα πέντε standards siblings του εκπέμπουν conformant UTF-8 metadata σε Delphi, Lazarus και plain Free Pascal builds χωρίς καμία codepage configuration από τον caller. Πλήρης API documentation και η τρέχουσα έκδοση βρίσκονται στη σελίδα προϊόντος PDFium Delphi Component