PDFium Delphi Component sastavlja XMP paket za PDF/A izlaz konkatenacijom UTF-8 fragmenata u AnsiString, a na Free Pascalu 3.2.2 taj je paket tiho prestao biti valjan UTF-8 čim je naslov dokumenta nosio ne-ASCII znak. ISO 19005-1 6.7.2 zahtijeva da tok metapodataka bude valjan UTF-8, pa je datoteka pala na provjeri. Verzija 3.103.1 popravlja sam enkoder, u StringToUtf8. Zanimljiv dio nije zakrpa. To je činjenica da je jedna nepromijenjena izvorna linija proizvodila ispravne bajtove pod Delphijem, ispravne bajtove u Lazarusovoj LCL aplikaciji i pokvarene bajtove u običnom konzolnom programu Free Pascala prevedenom iz identične jedinice. Tri zasebna ponašanja stringova Free Pascala moraju se posložiti prije nego što to dobije smisao, a svako je pojedinačno obranjivo
Zašto isti kod metapodataka emitira različite bajtove na Delphiju i FPC-u?
Jer string nije isti tip u ta dva kompajlera. FPC 3.2.2 u {$MODE Delphi} prevodi string u AnsiString označen s DefaultSystemCodePage, dok ga Delphi prevodi u UnicodeString. Svako polje metapodataka u TPdfASaveOptions deklarirano je kao string, pa Title, Author, Subject, Keywords, Creator i Producer na jednom kompajleru nose UTF-16 kodne jedinice, a na drugom jednobajtne znakove i oznaku kodne stranice. Isti zapis, drugi korisni teret. Same vrijednosti dolaze iz dokumenta kao UTF-16. TPdf.GetTitle i njegovi parnjaci vraćaju WString, što je na FPC-u WideString, a na Delphiju string, i SaveAsPdfAToStream popunjava svako prazno polje opcija iz Info rječnika prije ubrizgavanja oznaka. Ta je dodjela sužavajuća konverzija na Free Pascalu, a RTL je provodi kroz ciljnu kodnu stranicu stringa. U LCL programu LazUTF8 već je postavio DefaultSystemCodePage na CP_UTF8, pa sužavanje proizvodi UTF-8 i sve nizvodno slučajno bude ispravno. U običnom konzolnom programu isto sužavanje završi na ANSI kodnoj stranici, a StringToUtf8 zatim te oktete kopira nepromijenjene jer pretpostavlja da su već UTF-8. Šest mostova za spremanje dijeli ovaj oblik: SaveAsPdfAToStream, SaveAsPdfUaToStream, SaveAsPdfEToStream, SaveAsPdfXToStream, SaveAsPdfRToStream i SaveAsPdfVTToStream, svaki s vlastitim zapisom opcija
// PDFium.pas: pristupnici dokumenta uvijek su UTF-16
// WString = WideString na FPC-u, = string (UnicodeString) na Delphiju
function TPdf.GetTitle: WString;
// FPdfPdfa.pas: zapis opcija spremanja nosi metapodatke kao `string`
TPdfASaveOptions = record
Conformance: TPdfAConformance;
IccProfileData: TBytes;
Title: string; // UnicodeString na Delphiju
// 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 rječnika.
// Sužavanje je sada izričito napisano umjesto prepušteno implicitnoj konverziji:
if Eff.Title = '' then
Eff.Title := WStringToStr(GetTitle);
Usmjeravanje svih šest mostova kroz jedan pomoćnik WStringToStr ne mijenja ono što RTL radi, ali konverziju stavlja ondje gdje je čitatelj može vidjeti i uklonilo je 92 upozorenja o implicitnoj konverziji koja su skrivala upravo ovu klasu problema. To je zrcalna slika korupcije na strani Delphija opisane u bilješkama o zamkama među kompajlerima Delphi i FPC u izgradnjama PDFiuma, gdje konkatenacija na Delphiju uništava viši bajt koji Free Pascal čuva
Tri ponašanja Free Pascala koja poraze očiti popravak
Očiti je popravak pozvati UTF8Encode i završiti. Na FPC-u 3.2.2 u modu Delphi to zakaže tri puta, a svaki je neuspjeh tih
var
W: UnicodeString;
S: string;
U: UTF8String;
R: RawByteString;
Xmp: AnsiString;
begin
// Zamka 1: u modu Delphi varijabla UTF8String obični je AnsiString,
// pa dodjela oktete izravno transkodira natrag u hostovu kodnu stranicu
U := UTF8Encode(W);
// Zamka 2: S je već AnsiString, pa UTF8Encode ne radi baš ništa
R := UTF8Encode(S); // nema dekodiranja, nema kodiranja, nema pogreške
R := UTF8Encode(UnicodeString(S)); // ovaj doista kodira
// Zamka 3: konkatenacija sve operande ujedinjuje u kodnu stranicu odredišta,
// a odredište RawByteString nije iznimka
Xmp := Xmp + R;
end;
Prva zamka znači da kodirani rezultat mora ostati u AnsiStringu ili RawByteStringu u kojem je proizveden. Proslijedite ga kroz privremeni UTF8String na izlazu i poništili ste posao. Druga je ona koja se najdulje skriva jer se UTF8Encode(S) prevede, pokrene, vrati vrijednost ispravne duljine i ne napravi nikakvu konverziju kada je njegov argument već AnsiString; samo prvo proširenje u UnicodeString navodi poziv da išta dekodira. Treća je razlog zbog kojeg ispravan enkoder i dalje može proizvesti pokvaren dokument: BuildXmpBytes akumulira paket u lokalnom Xmp: AnsiStringu, a Free Pascal svaki operand konkatenacije pretvara u kodnu stranicu odredišne varijable i usput višebajtne sekvence vraća u jednobajtne ANSI oktete
Što SetCodePage s False doista jamči?
SetCodePage(RawByteString(Result), DefaultSystemCodePage, False) ponovno označuje string bez dodirivanja ijednog bajta. Treći je parametar Convert; predavanje False znači pretpostavi da je korisni teret već u ciljnoj kodnoj stranici i samo promijeni oznaku. To je namjerna laž o sadržaju: okteti su doista UTF-8, ali označavanje kao hostove kodne stranice sprječava konkatenaciju u trećoj zamci da ih pretvori. U XMP međuspremnik ulaze kao sirovi bajtovi i s druge strane izlaze nepromijenjeni
function StringToUtf8(const S: string): AnsiString;
begin
{$IFDEF UNICODE}
// Delphi: UTF8Encode već vraća oktete označene s CP_UTF8, a
// konkatenacija u AnsiString ih zadržava
Result := AnsiString(UTF8Encode(S));
{$ELSE}
// FPC: najprije proširi ili UTF8Encode ne radi ništa s argumentom AnsiString
Result := UTF8Encode(UnicodeString(S));
// Ponovno označi bez transkodiranja, kako bi okteti preživjeli konkatenaciju
// u međuspremnike označene ANSI-jem koji sastavljaju XMP pakete i PDF string objekte
if Length(Result) > 0 then
SetCodePage(RawByteString(Result), DefaultSystemCodePage, False);
{$ENDIF}
end;
Budite jasni o granici. Ponovno označavanje vrijedi samo za FPC i nije opći blagoslov za miješanje označenih i neoznačenih stringova. Ovdje radi jer nizvodno postoji točno jedan obrazac potrošača: dodati u AnsiString, a zatim međuspremnik zapisati kao bajtove. Sve što bi ponovno označenu vrijednost pokušalo tumačiti kao tekst u hostovoj kodnoj stranici pročitalo bi mojibake i to s pravom. Obrnuti smjer obrađuje se drugačije i identičan je na oba kompajlera: ulazni međuspremnik označite s CP_UTF8 pomoću SetCodePage(..., False), a zatim pozovite UTF8ToString
Zašto su regresijski testovi nosili istu zamku?
Jer test koji očekivane bajtove gradi iz izvornog literala testira kompajler, a ne biblioteku. Konstanta poput #$C3#$A9 zapisana u izvornoj datoteci Pascala nosi kodnu stranicu te datoteke u trenutku prevođenja, a kada se preda parametru AnsiString, RTL je ponovno kodira, što je upravo konverzija koja se testira. Očekivanje se mora sastaviti u izvođenju, bajt po bajt, i usporediti bajt po bajt jer = nad dvjema vrijednostima AnsiString s različitim oznakama prije usporedbe usklađuje kodne stranice i vraća veselo lažno negativan rezultat
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; // sužavanje koje se testira
Encoded := StringToUtf8(Narrowed);
finally
SetMultiByteConversionCodePage(Saved);
end;
// 'Caf' + U+00E9 kao UTF-8, izgrađeno u izvođenju da literal ne bude ponovno 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 je harness LCL program, pa je DefaultSystemCodePage CP_UTF8 i pogreška je nevidljiva dok je test ne promijeni. SetMultiByteConversionCodePage(1252) unutar try..finally ponovno stvara okruženje običnog konzolnog programa za trajanje jednog testa. End-to-end provjera ide dalje i potvrđuje oba smjera: XMP paket proizveden ubrizgavanjem oznake mora sadržavati $43 $61 $66 $C3 $A9 i ne smije sadržavati $43 $61 $66 $E9, pa buduća regresija koja se vrati na sirovi jednobajtni izlaz glasno pada umjesto da proizvede datoteku koja samo izgleda uvjerljivo u heksadecimalnom ispisu. Ako radite s ne-latiničnim metapodacima, ista disciplina proširivanja upravlja slučajevima u članku o emojijima i CJK tekstu koji kvare obradu WideChara u Delphiju
Gdje još slijeće sužavanje?
XMP je vidljiva žrtva, ali svaki most TBytes u string u istoj bazi koda bio je jednako izložen. Još su dva popravljena u v3.103.1: Utf8BytesToString i StringToUtf8Bytes u FPdfProduction, koji paket skupova podataka XFA vraćaju kroz string kako bi MergePdfXfaDatasets mogao zamijeniti vezane vrijednosti, te BytesToUtf8 u FPdfTrustedList, koji dekodira XML europskog popisa pouzdanih izvora nakon uklanjanja oznake redoslijeda bajtova. Oba sada spremaju međuspremnik u RawByteString, označuju ga s CP_UTF8 bez konverzije i dekodiraju s UTF8ToString. Jedan je modul već bio imun, a razlog vrijedi kopirati. XFDF pisač deklarira vlastiti tekstualni tip kao XFDFString, koji se pod FPC-om razrješava u WideString, a pod Delphijem u UnicodeString, pa njegov enkoder uopće ne vidi AnsiString označen kodnom stranicom. To je strukturni popravak: držite tekst u tipu UTF-16 do točne točke serijalizacije i dopustite jednoj uskoj funkciji da posjeduje konverziju u bajtove. Svaka pogreška u ovoj obitelji došla je iz polja string usred cjevovoda koji je inače na jednom kraju bio UTF-16, a na drugom okteti
Što provjeriti u vlastitom PDF kodu s dva kompajlera?
Ako isporučujete Object Pascal koji radi na oba kompajlera i zapisuje metapodatke u PDF sukladan standardu, četiri provjere pronalaze većinu ove klase prije nego što to učini validator
- Pretražite
UTF8Encodes argumentomstring. Na FPC-u je taj poziv no-op i to je linija s najvećim prinosom za reviziju - Svaku varijablu
UTF8Stringu modu Delphi tretirajte kao sumnjivu. Ondje je običniAnsiString, a dodjela kodiranih bajtova u nju transkodira ih natrag - Pokrenite barem jednu regresiju uz
SetMultiByteConversionCodePages jednobajtnom kodnom stranicom. LCL testni harness radi naCP_UTF8i nikad neće ponovno stvoriti običan konzolni program - Očekivane vektore bajtova gradite u izvođenju i uspoređujte ih oktet po oktet. I izvorni literali i
=prolaze kroz usklađivanje kodne stranice i sakrit će nedostatak koji tražite
Ništa od ovoga nije egzotična sitnica Free Pascala. To je običan trošak jezika koji je zadržao bajtno orijentirani tip stringa uz tip UTF-16, a dva su kompajlera razumno, ali različito, odlučila što bi string trebao značiti. Praktična posljedica za PDF rad je uska i oštra: metapodaci koji izgledaju dobro u vašem IDE-u mogu do XMP paketa stići kao nevaljani UTF-8, a ISO 19005-1 6.7.2 nije briga koji ih je kompajler ondje stavio. Ako gradite arhivski cjevovod, sloj kodiranja zaslužuje jednaku pažnju kao i ostatak tijeka usklađenosti PDF/A za arhiviranje koji ga okružuje. PDFium Delphi Component isporučuje te konverzije kao dio biblioteke, pa SaveAsPdfA i njegovih pet srodnih standardnih ulaza emitiraju sukladne UTF-8 metapodatke na Delphiju, Lazarusu i običnim izgradnjama Free Pascala bez ikakve konfiguracije kodne stranice od strane pozivatelja. Puna dokumentacija API-ja i aktualno izdanje nalaze se na stranici proizvoda PDFium Delphi Component