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
UTF8Encodes argumentomstring. Na FPC je toto volanie no-op a je to riadok s najvyššou návratnosťou na audit - Každú premennú
UTF8Stringv mode Delphi považujte za podozrivú. Je tam obyčajnýAnsiStringa priradenie zakódovaných bajtov do nej ich pretranskóduje späť - Spustite aspoň jednu regresiu pod
SetMultiByteConversionCodePages jednobajtovou codepage. LCL test harness beží naCP_UTF8a 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