PDFium Delphi Component sklapa XMP paket za PDF/A izlaz konkatenacijom UTF-8 fragmenata u AnsiString, a na Free Pascal-u 3.2.2 taj paket je tiho prestajao da bude validan UTF-8 čim bi naslov dokumenta nosio znak izvan ASCII-ja. ISO 19005-1 6.7.2 zahteva da metadata stream bude validan UTF-8, pa je fajl padao na validaciji. Verzija 3.103.1 popravlja sam encoder, u StringToUtf8. Zanimljiv deo nije patch. To je činjenica da je jedna nepromenjena izvorna linija proizvodila ispravne bajtove pod Delphi-jem, ispravne bajtove u Lazarus LCL aplikaciji i korumpirane bajtove u običnom Free Pascal console programu kompajliranom iz iste jedinice. Tri različita ponašanja Free Pascal stringova moraju da se poklope pre nego što to postane jasno, a svako od njih je zasebno odbranjivo
Zašto isti metadata kod emituje različite bajtove na Delphi-ju i FPC-u?
Zato što string nije isti tip u ta dva kompajlera. FPC 3.2.2 u režimu {$MODE Delphi} kompajlira string kao AnsiString označen sa DefaultSystemCodePage, dok ga Delphi kompajlira kao UnicodeString. Svako metadata polje u TPdfASaveOptions deklarisano je kao string, pa Title, Author, Subject, Keywords, Creator i Producer nose UTF-16 code unit-e na jednom kompajleru, a jednobajtne znakove plus codepage oznaku na drugom. Isti zapis, isto polje, drugačiji payload. Vrednosti same stižu iz dokumenta kao UTF-16. TPdf.GetTitle i srodne metode vraćaju WString, što je WideString na FPC-u, a string na Delphi-ju, dok SaveAsPdfAToStream popunjava svako prazno option polje iz Info dictionary-ja pre ubrizgavanja markera. Ta dodela je narrowing konverzija na Free Pascal-u, a RTL je izvršava kroz codepage ciljnog stringa. U LCL programu LazUTF8 je već postavio DefaultSystemCodePage na CP_UTF8, pa narrowing daje UTF-8 i sve nizvodno slučajno bude ispravno. U običnom console programu isto narrowing sleti na ANSI codepage, a StringToUtf8 zatim te oktete samo prekopira jer pretpostavlja da su već UTF-8. Šest save bridge-ova deli ovaj oblik: SaveAsPdfAToStream, SaveAsPdfUaToStream, SaveAsPdfEToStream, SaveAsPdfXToStream, SaveAsPdfRToStream i SaveAsPdfVTToStream, svaki sa sopstvenim option record-om
// PDFium.pas: accessor-i dokumenta uvek su UTF-16
// WString = WideString na FPC-u, = string (UnicodeString) na Delphi-ju
function TPdf.GetTitle: WString;
// FPdfPdfa.pas: save option record nosi metadata kao `string`
TPdfASaveOptions = record
Conformance: TPdfAConformance;
IccProfileData: TBytes;
Title: string; // UnicodeString na Delphi-ju
// AnsiString + DefaultSystemCodePage na FPC-u
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 popunjava prazna polja iz Info dictionary-ja.
// Narrowing je sada napisan eksplicitno umesto da ostane implicit:
if Eff.Title = '' then
Eff.Title := WStringToStr(GetTitle);
Usmeravanje svih šest bridge-ova kroz jedan WStringToStr helper ne menja ono što RTL radi, ali konverziju postavlja tamo gde čitalac može da je vidi i uklonilo je 92 upozorenja o implicitnoj konverziji koja su skrivala upravo ovu klasu problema. To je ogledalo korupcije na Delphi strani opisane u beleškama o cross-compiler zamkama Delphi-ja i FPC-a u PDFium build-ovima, gde konkatenacija na Delphi-ju uništava high byte koji Free Pascal čuva
Tri Free Pascal ponašanja koja poraze očiglednu popravku
Očigledna popravka jeste pozvati UTF8Encode i završiti posao. To na FPC-u 3.2.2 u režimu Delphi pada tri puta i svaki put tiho
var
W: UnicodeString;
S: string;
U: UTF8String;
R: RawByteString;
Xmp: AnsiString;
begin
// Zamka 1: u režimu Delphi promenljiva UTF8String je običan AnsiString,
// pa dodela transkodira oktete pravo nazad u host codepage
U := UTF8Encode(W);
// Zamka 2: S je već AnsiString, pa UTF8Encode ne radi baš ništa
R := UTF8Encode(S); // nema decode-a, nema encode-a, nema greške
R := UTF8Encode(UnicodeString(S)); // ova linija zaista enkodira
// Zamka 3: konkatenacija ujedinjuje svaki operand prema codepage-u odredišta,
// a RawByteString odredište nije izuzetak
Xmp := Xmp + R;
end;
Prva zamka znači da kodirani rezultat mora ostati u AnsiString ili RawByteString u kojem je proizveden. Provucite ga kroz privremeni UTF8String na izlazu i poništili ste posao. Druga zamka najduže se skriva, jer UTF8Encode(S) kompajlira, radi, vraća vrednost prave dužine i ne izvršava nikakvu konverziju kada je argument već AnsiString; tek prethodno proširenje u UnicodeString tera poziv da nešto dekodira. Treća zamka objašnjava kako ispravan encoder i dalje može da proizvede pokvaren dokument: BuildXmpBytes skuplja paket u lokalnom Xmp: AnsiString, a Free Pascal svaki operand konkatenacije pretvara u codepage odredišne promenljive i pri tome višebajtne sekvence ponovo spušta na jednobajtne ANSI bajtove
Šta zaista garantuje SetCodePage sa False?
SetCodePage(RawByteString(Result), DefaultSystemCodePage, False) ponovo označava string bez menjanja ijednog bajta. Treći parametar je Convert; prosleđivanje False znači „pretpostavi da je payload već u ciljnom codepage-u i samo promeni oznaku“. To je laž o sadržaju, namerno izgovorena: okteti su zaista UTF-8, ali označavanje kao host codepage jeste ono što sprečava konkatenaciju iz treće zamke da ih konvertuje. Oni se u XMP bafer pridružuju kao sirovi bajtovi i sa druge strane izlaze nepromenjeni
function StringToUtf8(const S: string): AnsiString;
begin
{$IFDEF UNICODE}
// Delphi: UTF8Encode već daje CP_UTF8 označene oktete, a
// konkatenacija u AnsiString ih čuva
Result := AnsiString(UTF8Encode(S));
{$ELSE}
// FPC: prvo proširi, inače je UTF8Encode nad AnsiString argumentom no-op
Result := UTF8Encode(UnicodeString(S));
// Ponovo označi bez transkodiranja, da okteti prežive konkatenaciju u
// ANSI-označenim baferima koji sklapaju XMP pakete i PDF string objekte
if Length(Result) > 0 then
SetCodePage(RawByteString(Result), DefaultSystemCodePage, False);
{$ENDIF}
end;
Budite precizni na granici. Retag je samo za FPC i nije opšta dozvola za mešanje označenih i neoznačenih stringova. Ovde radi zato što nizvodno postoji tačno jedan obrazac potrošnje: dodaj u AnsiString, a zatim bafer upiši kao bajtove. Sve što bi pokušalo da retagovanu vrednost tumači kao tekst u host codepage-u pročitalo bi mojibake i to s pravom. Obrnuti smer radi drugačije, ali isto na oba kompajlera: ulazni bafer označite sa CP_UTF8 pomoću SetCodePage(..., False), pa pozovite UTF8ToString
Zašto su regresioni testovi nosili istu zamku?
Zato što test koji očekivane bajtove gradi iz source literala testira kompajler, a ne biblioteku. Konstanta poput #$C3#$A9 napisana u Pascal source fajlu nosi compile-time codepage tog fajla, a kada se prosledi AnsiString parametru RTL je ponovo kodira, što je upravo konverzija koja se testira. Očekivanje mora da se sastavi tokom izvršavanja, bajt po bajt, i da se poredi bajt po bajt, jer = nad dva AnsiString objekta sa različitim oznakama pomiri codepage-ove pre poređenja i vrati vedar 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 koji testiramo
Encoded := StringToUtf8(Narrowed);
finally
SetMultiByteConversionCodePage(Saved);
end;
// 'Caf' + U+00E9 kao UTF-8, sastavljeno tokom izvršavanja da literal ne bi bio ponovo kodiran
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;
Sam harness je LCL program, pa je DefaultSystemCodePage CP_UTF8, a bag ostaje nevidljiv sve dok ga test ne promeni. SetMultiByteConversionCodePage(1252) unutar try..finally reprodukuje okruženje običnog console programa za trajanje jednog testa. End-to-end provera ide dalje i potvrđuje oba smera: XMP paket nastao ubrizgavanjem markera mora da sadrži $43 $61 $66 $C3 $A9, a ne sme da sadrži $43 $61 $66 $E9, pa buduća regresija koja vrati sirovi jednobajtni izlaz pada glasno umesto da proizvodi fajl koji samo izgleda uverljivo u hex dump-u. Ako radite sa metadata-ma koji nisu latinični, ista disciplina proširivanja upravlja slučajevima u tekstu o emoji i CJK tekstu koji lome WideChar obradu u Delphi-ju
Gde još narrowing završava
XMP je vidljiva žrtva, ali svaki TBytes to string bridge u istom codebase-u imao je isto izlaganje. Još dva su popravljena u v3.103.1: Utf8BytesToString i StringToUtf8Bytes u FPdfProduction, koji XFA datasets paket vraćaju kroz string kako bi MergePdfXfaDatasets mogao da zameni vezane vrednosti, i BytesToUtf8 u FPdfTrustedList, koji dekodira XML evropske trusted liste nakon uklanjanja byte-order markera. Oba sada bafer pripreme u RawByteString, označe ga sa CP_UTF8 bez konverzije i dekodiraju pomoću UTF8ToString. Jedan modul je već bio imun i razlog vredi kopirati. XFDF writer deklariše sopstveni tip teksta kao XFDFString, koji se pod FPC-om razrešava u WideString, a pod Delphi-jem u UnicodeString, pa njegov encoder nikada ne vidi codepage-ovani AnsiString. To je strukturalna popravka: tekst čuvajte u UTF-16 tipu sve do tačne tačke serijalizacije, a jednoj uskoj funkciji prepustite konverziju u bajtove. Svaki bag iz ove porodice nastao je zato što je string polje sedelo na sredini pipeline-a koji je na jednom kraju bio UTF-16, a na drugom okteti
Šta proveriti u sopstvenom PDF kodu za dva kompajlera
Ako isporučujete Object Pascal koji radi na oba kompajlera i upisuje metadata u PDF usklađen sa standardima, četiri provere pronalaze većinu ove klase problema pre validatora
- Grep-ujte
UTF8Encodesa argumentom tipastring. Na FPC-u je taj poziv no-op i to je linija sa najvećim prinosom za audit - Svaku promenljivu
UTF8Stringu režimu Delphi tretirajte kao sumnjivu. Tamo je običanAnsiString, a dodela kodiranih bajtova u nju ih transkodira nazad - Pokrenite bar jednu regresiju pod
SetMultiByteConversionCodePagesa jednobajtnim codepage-om. LCL test harness radi podCP_UTF8i nikada neće reprodukovati običan console program - Vektore očekivanih bajtova gradite tokom izvršavanja i poredite oktet po oktet. Source literali i
=oba prolaze kroz pomirenje codepage-a i sakriće defekt koji tražite
Ništa od ovoga nije egzotična Free Pascal trivia. To je obična cena jezika koji je zadržao byte-oriented string tip uporedo sa UTF-16 tipom, a dva kompajlera napravila su razuman, ali različit izbor oko toga šta string treba da znači. Praktična posledica za PDF rad je uska i oštra: metadata koja izgleda dobro u IDE-u može do XMP paketa stići kao nevažeći UTF-8, a ISO 19005-1 6.7.2 ne mari koji ju je kompajler tamo stavio. Ako gradite arhivski pipeline, sloj kodiranja zaslužuje jednaku pažnju kao i ostatak workflow-a za PDF/A arhivsku usklađenost oko njega. PDFium Delphi Component ove konverzije isporučuje kao deo biblioteke, pa SaveAsPdfA i njegovih pet standardnih srodnika emituju usklađene UTF-8 metadata-e na Delphi-ju, u Lazarus-u i u običnim Free Pascal build-ovima bez ikakve codepage konfiguracije od pozivaoca. Kompletna API dokumentacija i aktuelno izdanje nalaze se na stranici proizvoda PDFium Delphi Component