Techninis straipsnis

FPC koduotės spąstai sugadina PDF/A XMP metaduomenis Delphi

PDFium Delphi komponentas PDF/A išvesties XMP paketą surenka sujungdamas UTF-8 fragmentus į AnsiString, o Free Pascal 3.2.2 aplinkoje šis paketas tyliai nustojo būti galiojantis UTF-8 vos tik dokumento pavadinime atsirado ne ASCII simbolis. ISO 19005-1 6.7.2 reikalauja, kad metaduomenų srautas būtų galiojantis UTF-8, todėl failas neperėjo tikrinimo. Versija 3.103.1 pataiso patį koduotuvą, StringToUtf8 funkcijoje. Įdomi ne pataisa. Įdomu tai, kad viena nepakeista šaltinio eilutė Delphi aplinkoje sukūrė teisingus baitus, Lazarus LCL programoje – taip pat teisingus, o paprastoje Free Pascal konsolės programoje, sukompiliuotoje iš to paties unito, – sugadintus. Kad tai taptų suprantama, turi sutapti trys atskiri Free Pascal eilučių elgesiai, ir kiekvienas jų atskirai yra pagrįstas

Kodėl tas pats metaduomenų kodas Delphi ir FPC išveda skirtingus baitus?

Nes string abiejuose kompiliatoriuose nėra tas pats tipas. FPC 3.2.2 su {$MODE Delphi} string kompiliuoja į AnsiString, pažymėtą DefaultSystemCodePage, o Delphi jį kompiliuoja į UnicodeString. Kiekvienas TPdfASaveOptions metaduomenų laukas deklaruotas kaip string, todėl Title, Author, Subject, Keywords, Creator ir Producer viename kompiliatoriuje turi UTF-16 kodinius vienetus, kitame – vieno baito simbolius ir koduotės žymę. Tas pats įrašas, tas pats laukas, kitokia naudingoji apkrova. Pačios reikšmės iš dokumento ateina kaip UTF-16. TPdf.GetTitle ir jo broliai grąžina WString, kuris FPC yra WideString, o Delphi – string, ir SaveAsPdfAToStream prieš įterpdama žymes užpildo kiekvieną tuščią parinkties lauką iš Info žodyno. Free Pascal aplinkoje tai yra siaurinanti konversija, o RTL ją atlieka per paskirties eilutės koduotę. LCL programoje LazUTF8 jau nustatė DefaultSystemCodePage į CP_UTF8, todėl siaurinimas sukuria UTF-8 ir visa tolesnė grandinė atsitiktinai būna teisinga. Paprastoje konsolės programoje tas pats siaurinimas patenka į ANSI koduotę, o StringToUtf8 tada nukopijuoja oktetus nepakeistus, nes mano, kad jie jau UTF-8. Šeši išsaugojimo tiltai turi tokią pačią formą: SaveAsPdfAToStream, SaveAsPdfUaToStream, SaveAsPdfEToStream, SaveAsPdfXToStream, SaveAsPdfRToStream ir SaveAsPdfVTToStream, kiekvienas su savo parinkčių įrašu

// PDFium.pas: dokumento prieigos metodai visada yra UTF-16
//   WString = WideString FPC aplinkoje, = string (UnicodeString) Delphi aplinkoje
function TPdf.GetTitle: WString;

// FPdfPdfa.pas: išsaugojimo parinkties įraše metaduomenys yra `string`
TPdfASaveOptions = record
  Conformance: TPdfAConformance;
  IccProfileData: TBytes;
  Title: string;      // UnicodeString Delphi aplinkoje
                      // AnsiString + DefaultSystemCodePage FPC aplinkoje
  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 papildo tuščius laukus iš Info žodyno.
// Siaurinimas dabar išreikštas aiškiai, o ne paliktas numanomas:
if Eff.Title = '' then
  Eff.Title := WStringToStr(GetTitle);

Visus šešis tiltus nukreipus per vieną WStringToStr pagalbinę funkciją RTL elgesys nepasikeičia, tačiau konversija atsiduria vietoje, kur ją mato skaitytojas, ir išvalomi 92 numanomų konversijų įspėjimai, kurie slėpė būtent šią problemų klasę. Tai yra veidrodinis Delphi pusės sugadinimo atvejis, aprašytas mūsų pastabose apie Delphi ir FPC kryžminio kompiliavimo spąstus PDFium build, kur Delphi aplinkoje sujungimas sunaikina aukštą baitą, kurį Free Pascal išsaugo

Trys Free Pascal elgsenos, sugadinančios akivaizdų pataisymą

Akivaizdus pataisymas – iškviesti UTF8Encode ir baigti. FPC 3.2.2 su mode Delphi tai tyliai žlunga tris kartus

var
  W: UnicodeString;
  S: string;
  U: UTF8String;
  R: RawByteString;
  Xmp: AnsiString;
begin
  // Spąstai 1: mode Delphi UTF8String *kintamasis* yra paprastas AnsiString,
  // todėl priskyrimas oktetus iš karto perkoduoja į pagrindinę koduotę
  U := UTF8Encode(W);

  // Spąstai 2: S jau yra AnsiString, todėl UTF8Encode visai nieko nedaro
  R := UTF8Encode(S);                 // jokio dekodavimo, kodavimo ar klaidos
  R := UTF8Encode(UnicodeString(S));  // šis iš tikrųjų koduoja

  // Spąstai 3: sujungimas visus operandus suvienodina pagal paskirties koduotę,
  // ir RawByteString paskirtis nėra išimtis
  Xmp := Xmp + R;
end;

Pirmieji spąstai reiškia, kad užkoduotas rezultatas turi likti tame AnsiString arba RawByteString, kuriame jis sukurtas. Perduokite jį per UTF8String laikinąjį kintamąjį išėjimo kelyje ir darbą panaikinsite. Antrieji spąstai ilgiausiai slepiasi, nes UTF8Encode(S) kompiliuojasi, veikia, grąžina tinkamo ilgio reikšmę ir neatlieka jokios konversijos, kai argumentas jau yra AnsiString; tik pirmiausia išplėtus į UnicodeString iškvietimas ką nors iškoduoja. Tretieji spąstai paaiškina, kaip teisingas koduotuvas vis tiek gali sukurti sugadintą dokumentą: BuildXmpBytes paketą kaupia vietiniame Xmp: AnsiString, o Free Pascal kiekvieną sujungimo operandą konvertuoja į paskirties kintamojo koduotę ir taip daugiabaites sekas pakeliui atgal suspaudžia į vieno baito ANSI reikšmes

Ką iš tikrųjų garantuoja SetCodePage su False?

SetCodePage(RawByteString(Result), DefaultSystemCodePage, False) pervadina eilutės koduotės žymę nekeisdama nė vieno baito. Trečias parametras yra Convert; perdavus False sakoma „manyk, kad turinys jau yra paskirties koduotėje ir tik pakeisk žymę“. Tai melas apie turinį, sąmoningai pasakytas tam, kad veiktų: oktetai iš tiesų yra UTF-8, bet jų pažymėjimas kaip pagrindinės sistemos koduotės neleidžia trečiųjų spąstų sujungimui jų konvertuoti. Jie prisijungia prie XMP buferio kaip neapdoroti baitai ir iš kitos pusės išeina nepakitę

function StringToUtf8(const S: string): AnsiString;
begin
{$IFDEF UNICODE}
  // Delphi: UTF8Encode jau grąžina CP_UTF8 pažymėtus oktetus, o
  // sujungimas į AnsiString juos išlaiko
  Result := AnsiString(UTF8Encode(S));
{$ELSE}
  // FPC: pirmiausia išplėsti, kitaip UTF8Encode su AnsiString argumentu nieko nedaro
  Result := UTF8Encode(UnicodeString(S));
  // Peržymėti be transkodavimo, kad oktetai išliktų juos sujungiant į
  // ANSI pažymėtus buferius, surenkančius XMP paketus ir PDF eilučių objektus
  if Length(Result) > 0 then
    SetCodePage(RawByteString(Result), DefaultSystemCodePage, False);
{$ENDIF}
end;

Aiškiai žinokite ribą. Peržymėjimas skirtas tik FPC ir nėra bendras leidimas maišyti pažymėtas bei nepažymėtas eilutes. Čia jis veikia todėl, kad toliau egzistuoja lygiai vienas vartotojo modelis: pridėti prie AnsiString, tada buferį išvesti kaip baitus. Bet kas, kas bandytų peržymėtą reikšmę interpretuoti kaip tekstą pagrindinėje koduotėje, perskaitytų mojibake ir būtų teisus. Atvirkštinė kryptis tvarkoma kitaip ir abiejuose kompiliatoriuose vienodai: gaunamą buferį su SetCodePage(..., False) pažymėti CP_UTF8, tada kviesti UTF8ToString

Kodėl regresijos testai turėjo tuos pačius spąstus?

Nes testas, kuris laukiamus baitus sukuria iš šaltinio literalo, tikrina kompiliatorių, o ne biblioteką. Toks literalas kaip #$C3#$A9, parašytas Pascal šaltinio faile, turi to failo kompiliavimo metu nustatytą koduotę, o perduotas AnsiString parametrui RTL jį perkoduoja, ir tai būtent yra testuojama konversija. Tikėtina reikšmė turi būti surinkta vykdymo metu, baitas po baito, ir palyginta taip pat, nes = dviem AnsiString reikšmėms su skirtingomis žymėmis prieš palygindamas sulygina koduotes ir grąžina malonų klaidingą neigiamą 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;                    // čia tikrinamas siaurinimas
    Encoded := StringToUtf8(Narrowed);
  finally
    SetMultiByteConversionCodePage(Saved);
  end;
  // 'Caf' + U+00E9 kaip UTF-8, sukuriama vykdymo metu, kad literalas nebūtų perkoduotas
  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;

Pats testų įrankis yra LCL programa, todėl DefaultSystemCodePage yra CP_UTF8, ir klaida nematoma, kol testas jos nepakeičia. SetMultiByteConversionCodePage(1252) bloke try..finally atkuria paprastos konsolės aplinką vieno testo trukmei. Galutinis patikrinimas eina toliau ir tikrina abi kryptis: žymių įterpimo sukurtame XMP pakete turi būti $43 $61 $66 $C3 $A9 ir neturi būti $43 $61 $66 $E9, todėl būsima regresija, grąžinanti vieno baito išvestį, garsiai sugenda, o ne sukuria failą, kuris šešioliktainėje išklotinėje atrodo tikėtinas. Jei dirbate su ne lotyniškais metaduomenimis, tas pats išplėtimo principas valdo ir atvejus iš emoji ir CJK teksto, sugadinančio WideChar tvarkymą Delphi aplinkoje

Kur dar atsiduria siaurinimas?

XMP yra matoma auka, bet toks pats pavojus gresia bet kuriam TBytes ir string tiltui toje pačioje kodų bazėje. v3.103.1 dar pataisytos dvi vietos: Utf8BytesToString ir StringToUtf8Bytes FPdfProduction, kurios XFA duomenų rinkinio paketą perveda per eilutę, kad MergePdfXfaDatasets galėtų pakeisti susietas reikšmes, ir BytesToUtf8 FPdfTrustedList, kuri po baitų tvarkos žymės pašalinimo dekoduoja Europos patikimų sąrašų XML. Dabar abi vietos buferį laiko RawByteString, be konvertavimo pažymi CP_UTF8 ir dekoduoja su UTF8ToString. Vienas modulis jau buvo apsaugotas, ir verta nukopijuoti priežastį. XFDF rašyklė savo teksto tipą deklaruoja kaip XFDFString, kuris FPC išsprendžiamas į WideString, o Delphi – į UnicodeString, todėl jos koduotuvas niekada nemato koduotos AnsiString. Tai struktūrinis pataisymas: laikyti tekstą UTF-16 tipe iki tikslaus serializavimo taško ir leisti vienai siaurai funkcijai valdyti konversiją į baitus. Kiekviena šios šeimos klaida atsirado dėl to, kad string laukas sėdėjo konvejerio viduryje, kurio viename gale buvo UTF-16, kitame – oktetai

Ką tikrinti savo dviejų kompiliatorių PDF kode?

Jei siunčiate Object Pascal, veikiantį abiejuose kompiliatoriuose ir rašantį metaduomenis į standartus atitinkantį PDF, keturi patikrinimai didžiąją dalį šios klasės problemų randa anksčiau už tikrintuvą

  • Ieškokite UTF8Encode iškvietimų su string argumentu. FPC aplinkoje toks iškvietimas yra no-op ir tai daugiausia naudos duodanti eilutė, kurią verta audituoti
  • Mode Delphi kiekvieną UTF8String kintamąjį laikykite įtartinu. Ten jis yra paprastas AnsiString, o į jį priskirti užkoduoti baitai perkoduojami atgal
  • Paleiskite bent vieną regresiją su SetMultiByteConversionCodePage ir vieno baito koduote. LCL testų programa veikia su CP_UTF8 ir niekada neatkurs paprastos konsolės programos
  • Laukiamus baitų vektorius kurkite vykdymo metu ir lyginkite oktetą po okteto. Šaltinio literalai ir = abu pereina per koduotės sulyginimą ir paslėps jūsų ieškomą defektą

Čia nėra jokios egzotiškos Free Pascal išminties. Tai įprasta kalbos, išlaikiusios baitams skirtą eilutės tipą greta UTF-16, kaina, o abu kompiliatoriai pagrįstai, bet skirtingai pasirinko, ką turi reikšti string. Praktinė pasekmė PDF darbui siaura ir aštri: metaduomenys, kurie IDE atrodo tinkami, į XMP paketą gali patekti kaip negaliojantis UTF-8, o ISO 19005-1 6.7.2 nesvarbu, kuris kompiliatorius juos ten įdėjo. Jei kuriate archyvavimo konvejerį, koduotės sluoksniui reikia skirti tiek pat dėmesio, kiek likusiam PDF/A archyvavimo atitikties procesui. PDFium Delphi komponentas šias konversijas pateikia kaip bibliotekos dalį, todėl SaveAsPdfA ir penki jo standartų broliai Delphi, Lazarus ir paprasto Free Pascal build aplinkoje išveda atitinkančius UTF-8 metaduomenis be jokios iškvietėjo koduotės konfigūracijos. Visa API dokumentacija ir dabartinis leidimas pateikti PDFium Delphi komponento produkto puslapyje