Odborný článok

FPC codepage rozbíja PDF/A XMP metadata v Delphi

PDFium Delphi Component skladá XMP packet pre výstup PDF/A zreťazením UTF-8 fragmentov do AnsiString a vo Free Pascale 3.2.2 tento packet potichu prestal byť platným UTF-8 v okamihu, keď názov dokumentu niesol non-ASCII znak. ISO 19005-1 6.7.2 vyžaduje, aby metadata stream bol platný UTF-8, takže súbor neprešiel validáciou. Verzia 3.103.1 opravuje samotný encoder, v StringToUtf8. Zaujímavý nie je patch. Zaujímavé je, že jeden nezmenený riadok zdroja vytvoril správne bajty pod Delphi, správne bajty v aplikácii Lazarus LCL a poškodené bajty v obyčajnom konzolovom programe Free Pascalu skompilovanom z identického unitu. Kým to začne dávať zmysel, musia sa zarovnať tri samostatné správania Free Pascal stringov a každé z nich je samo osebe obhájiteľné

Prečo rovnaký kód metadata emituje na Delphi a FPC iné bajty?

Pretože string nie je na oboch kompilátoroch rovnaký typ. FPC 3.2.2 v {$MODE Delphi} kompiluje string ako AnsiString označený DefaultSystemCodePage, zatiaľ čo Delphi ho kompiluje ako UnicodeString. Každé metadata pole v TPdfASaveOptions je deklarované ako string, takže Title, Author, Subject, Keywords, Creator a Producer nesú na jednom kompilátore UTF-16 code units a na druhom jednobajtové znaky plus codepage label. Rovnaký record, iný payload. Hodnoty samy prichádzajú z dokumentu ako UTF-16. TPdf.GetTitle a jeho súrodenci vracajú WString, čo je na FPC WideString a na Delphi string, a SaveAsPdfAToStream doplní každé prázdne option field z Info dictionary pred vložením markerov. Toto priradenie je na Free Pascale narrowing conversion a RTL ho vykoná cez codepage cieľového stringu. V programe LCL už LazUTF8 nastavilo DefaultSystemCodePage na CP_UTF8, takže narrowing vytvorí UTF-8 a všetko downstream sa náhodou správa správne. V obyčajnom konzolovom programe rovnaký narrowing dopadne na ANSI codepage a StringToUtf8 potom tieto octets skopíruje bez zmeny, pretože predpokladá, že už sú UTF-8. Šesť save bridges má tento tvar: SaveAsPdfAToStream, SaveAsPdfUaToStream, SaveAsPdfEToStream, SaveAsPdfXToStream, SaveAsPdfRToStream a SaveAsPdfVTToStream, každý s vlastným option recordom

// PDFium.pas: accessors dokumentu sú vždy UTF-16
//   WString = WideString na FPC, = string (UnicodeString) na Delphi
function TPdf.GetTitle: WString;

// FPdfPdfa.pas: save option record nesie metadata ako `string`
TPdfASaveOptions = record
  Conformance: TPdfAConformance;
  IccProfileData: TBytes;
  Title: string;      // UnicodeString na Delphi
                      // AnsiString + DefaultSystemCodePage na 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 doplní prázdne polia z Info dictionary.
// Narrowing je teraz zapísaný explicitne namiesto ponechania implicitným:
if Eff.Title = '' then
  Eff.Title := WStringToStr(GetTitle);

Vedenie všetkých šiestich bridges cez jediný helper WStringToStr nemení to, čo robí RTL, ale umiestni konverziu tam, kde ju čitateľ vidí, a odstránilo 92 warningov implicitných konverzií, ktoré maskovali práve túto triedu problému. Je to zrkadlový obraz korupcie na strane Delphi opísanej v poznámkach o pasciach cross-compilera Delphi a FPC v buildoch PDFium, kde concatenation na Delphi zničí high byte, ktorý Free Pascal zachová

Tri správania Free Pascalu, ktoré porazia očividnú opravu

Očividná oprava je zavolať UTF8Encode a skončiť. Vo FPC 3.2.2 v mode Delphi zlyhá trikrát a každé zlyhanie je tiché

var
  W: UnicodeString;
  S: string;
  U: UTF8String;
  R: RawByteString;
  Xmp: AnsiString;
begin
  // Pasca 1: v mode Delphi je premenná UTF8String obyčajný AnsiString,
  // takže priradenie pretranskóduje octets priamo späť na host codepage
  U := UTF8Encode(W);

  // Pasca 2: S už je AnsiString, takže UTF8Encode neurobí vôbec nič
  R := UTF8Encode(S);                 // bez decode, bez encode, bez chyby
  R := UTF8Encode(UnicodeString(S));  // toto naozaj encoduje

  // Pasca 3: concatenation zjednotí každý operand na destination codepage
  // a RawByteString destination nie je výnimkou
  Xmp := Xmp + R;
end;

Pasca jedna znamená, že zakódovaný výsledok musí zostať v AnsiString alebo RawByteString, v ktorom vznikol. Prežeňte ho cestou von cez dočasný UTF8String a prácu ste vrátili späť. Pasca dva sa skrýva najdlhšie, pretože UTF8Encode(S) sa skompiluje, spustí, vráti hodnotu správnej dĺžky a nevykoná vôbec žiadnu konverziu, keď je argument už AnsiString; až prvé rozšírenie na UnicodeString prinúti volanie niečo dekódovať. Pasca tri vysvetľuje, prečo môže správny encoder stále vyrobiť poškodený dokument: BuildXmpBytes hromadí packet v lokálnom Xmp: AnsiString a Free Pascal prevedie každý operand concatenation na codepage cieľovej premennej, čím cestou dnu zloží multibajtové sekvencie späť do jednobajtových ANSI bajtov

Čo v skutočnosti garantuje SetCodePage s False?

SetCodePage(RawByteString(Result), DefaultSystemCodePage, False) preoznačí string bez jediného zásahu do bajtu. Tretí parameter je Convert; odovzdanie False znamená „predpokladaj, že payload už je v cieľovej codepage a iba zmeň tag“. Je to úmyselná lož o obsahu: octets sú naozaj UTF-8, ale označenie host codepage je to, čo zabráni concatenation z pasce tri, aby ich konvertovala. Do XMP bufferu sa pripoja ako raw bytes a na druhej strane vyjdú nezmenené

function StringToUtf8(const S: string): AnsiString;
begin
{$IFDEF UNICODE}
  // Delphi: UTF8Encode už vracia octets s tagom CP_UTF8 a
  // concatenation do AnsiString ich zachová
  Result := AnsiString(UTF8Encode(S));
{$ELSE}
  // FPC: najprv rozšír, inak je UTF8Encode nad AnsiString argumentom no-op
  Result := UTF8Encode(UnicodeString(S));
  // Preoznač bez transkódovania, aby octets prežili concatenation do
  // ANSI-tagovaných bufferov, ktoré skladajú XMP packety a PDF string objekty
  if Length(Result) > 0 then
    SetCodePage(RawByteString(Result), DefaultSystemCodePage, False);
{$ENDIF}
end;

Majte jasno v hranici. Retag je iba FPC a nie je všeobecným požehnaním miešať tagované a netagované stringy. Tu funguje preto, že downstream existuje presne jeden consumer pattern: pripojiť do AnsiString a potom buffer zapísať ako bajty. Čokoľvek, čo by sa snažilo retagged hodnotu interpretovať ako text na host codepage, by prečítalo mojibake a oprávnene. Opačný smer sa rieši opačne a na oboch kompilátoroch je identický: vstupný buffer označte CP_UTF8 pomocou SetCodePage(..., False) a potom zavolajte UTF8ToString

Prečo niesli regresné testy tú istú pascu?

Pretože test, ktorý zostaví očakávané bajty zo source literálu, testuje kompilátor, nie knižnicu. Konštanta ako #$C3#$A9 v Pascal source file nesie compile-time codepage toho súboru a pri odovzdaní parametru AnsiString ju RTL znovu zakóduje, čo je presne konverzia, ktorú testujete. Očakávanie sa musí zostaviť za behu, bajt po bajte, a porovnať bajt po bajte, pretože = na dvoch hodnotách AnsiString s odlišnými tagmi pred porovnaním zosúladí codepages a vráti veselý 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, ktoré testujeme
    Encoded := StringToUtf8(Narrowed);
  finally
    SetMultiByteConversionCodePage(Saved);
  end;
  // 'Caf' + U+00E9 ako UTF-8, zostavené za behu, aby sa literál nemohol znovu zakódovať
  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 sám je LCL program, takže DefaultSystemCodePage je CP_UTF8 a bug je neviditeľný, kým ho test neprepne. SetMultiByteConversionCodePage(1252) vo vnútri try..finally reprodukuje prostredie obyčajnej konzoly počas jedného testu. End-to-end kontrola ide ďalej a overí oba smery: XMP packet vytvorený injection markerov musí obsahovať $43 $61 $66 $C3 $A9 a nesmie obsahovať $43 $61 $66 $E9, takže budúca regresia návratu k surovému jednobajtovému výstupu zlyhá hlasno namiesto výroby súboru, ktorý vyzerá vierohodne iba v hex dumpe. Ak pracujete s non-Latin metadátami, rovnaká disciplína rozširovania riadi prípady v článku o emoji a CJK texte, ktoré rozbíjajú prácu s WideChar v Delphi

Kam ešte dopadá narrowing

XMP je viditeľná obeť, ale rovnakú expozíciu mala každá TBytes to string bridge v tej istej codebase. Vo v3.103.1 sa opravili ďalšie dve: Utf8BytesToString a StringToUtf8Bytes v FPdfProduction, ktoré robia round-trip packetu XFA datasets cez string, aby MergePdfXfaDatasets mohol substituovať viazané hodnoty, a BytesToUtf8 v FPdfTrustedList, ktorý dekóduje XML európskeho trusted listu po odstránení byte-order marku. Obe teraz pripravia buffer v RawByteString, označia ho CP_UTF8 bez konverzie a dekódujú pomocou UTF8ToString. Jeden modul bol už imúnny a dôvod sa oplatí kopírovať. XFDF writer deklaruje vlastný textový typ ako XFDFString, ktorý sa pod FPC vyrieši na WideString a pod Delphi na UnicodeString, takže jeho encoder nikdy neuvidí codepage-tagged AnsiString. To je štrukturálna oprava: držte text v UTF-16 type až po presný bod serializácie a jediná úzka funkcia nech vlastní konverziu na bytes. Každý bug v tejto rodine vznikol z string fieldu sediaceho uprostred pipeline, ktorá bola inak na jednom konci UTF-16 a na druhom octets

Čo skontrolovať vo vlastnom dual-compiler PDF kóde

Ak dodávate Object Pascal bežiaci na oboch kompilátoroch a zapisujúci metadata do štandardne vyhovujúceho PDF, štyri kontroly nájdu väčšinu tejto triedy ešte pred validátorom

  • Grepnite UTF8Encode s argumentom string. Na FPC je toto volanie no-op a je to riadok s najvyššou návratnosťou na audit
  • Každú premennú UTF8String v mode Delphi považujte za podozrivú. Je tam obyčajný AnsiString a priradenie zakódovaných bajtov do nej ich pretranskóduje späť
  • Spustite aspoň jednu regresiu pod SetMultiByteConversionCodePage s jednobajtovou codepage. LCL test harness beží na CP_UTF8 a nikdy nezreprodukuje obyčajný konzolový program
  • Očakávané byte vectors zostavujte za behu a porovnávajte octet po octete. Source literály aj = prechádzajú codepage reconciliation a skryjú defect, ktorý hľadáte

Nič z toho nie je exotická trivia Free Pascalu. Je to obyčajná cena jazyka, ktorý ponechal byte-oriented string type vedľa UTF-16 typu a dva kompilátory urobili rozumné, ale odlišné voľby o tom, čo má string znamenať. Praktický dôsledok pre PDF je úzky a ostrý: metadata, ktoré v IDE vyzerajú správne, môžu do XMP packetu doraziť ako neplatné UTF-8 a ISO 19005-1 6.7.2 nezaujíma, ktorý kompilátor ich tam vložil. Ak budujete archivačný pipeline, encoding layer si zaslúži rovnakú pozornosť ako zvyšok workflow pre archivačnú zhodu PDF/A, ktorý ju obklopuje. PDFium Delphi Component dodáva tieto konverzie ako súčasť knižnice, takže SaveAsPdfA a jeho päť súrodencov pre iné štandardy emitujú vyhovujúce UTF-8 metadata na Delphi, v Lazare aj v plain Free Pascal buildoch bez konfigurácie codepage zo strany volajúceho. Úplná dokumentácia API a aktuálny release sú na produktovej stránke PDFium Delphi Component