Tehnički članak

FPC codepage zamke kvare PDF/A XMP u Delphiju

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 UTF8Encode s argumentom string. Na FPC-u je taj poziv no-op i to je linija s najvećim prinosom za reviziju
  • Svaku varijablu UTF8String u modu Delphi tretirajte kao sumnjivu. Ondje je obični AnsiString, a dodjela kodiranih bajtova u nju transkodira ih natrag
  • Pokrenite barem jednu regresiju uz SetMultiByteConversionCodePage s jednobajtnom kodnom stranicom. LCL testni harness radi na CP_UTF8 i 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