PDFium Delphi Component kokoaa PDF/A-tulosteen XMP-paketin liittämällä UTF-8-palaset yhteen AnsiString-muuttujassa, ja Free Pascal 3.2.2:ssa paketti lakkasi huomaamatta olemasta kelvollista UTF-8:aa heti kun dokumentin otsikossa oli ei-ASCII-merkki. ISO 19005-1 6.7.2 vaatii metadatan streamin olevan kelvollista UTF-8:aa, joten tiedosto epäonnistui validoinnissa. Versio 3.103.1 korjaa itse enkooderin kohdassa StringToUtf8. Kiinnostava osa ei ole patch. Se on että yksi muuttumaton lähdekoodirivi tuotti oikeat tavut Delphillä, oikeat tavut Lazarus LCL -sovelluksessa ja korruptoituneet tavut tavallisessa Free Pascal -konsoliohjelmassa joka käännettiin samasta unitista. Kolmen erillisen Free Pascal -merkkijonokäyttäytymisen täytyy osua yhteen ennen kuin tämä käy järkeen, ja jokainen niistä on yksinään puolusteltava
Miksi sama metadatakoodi tuottaa eri tavut Delphillä ja FPC:llä?
Koska string ei ole sama tyyppi molemmissa kääntäjissä. FPC 3.2.2 {$MODE Delphi} -tilassa kääntää string-tyypin DefaultSystemCodePage-arvolla merkityksi AnsiString-tyypiksi, kun taas Delphi kääntää sen UnicodeString-tyypiksi. Jokainen TPdfASaveOptions-tietueen metadatakenttä on määritelty string-tyypiksi, joten Title, Author, Subject, Keywords, Creator ja Producer kantavat yhdessä kääntäjässä UTF-16-koodiyksiköitä ja toisessa yksitavuisia merkkejä sekä codepage-merkintää. Sama record, eri payload. Arvot saapuvat dokumentista itsestään UTF-16-muodossa. TPdf.GetTitle ja sisarmetodit palauttavat WString-tyypin, joka on FPC:ssä WideString ja Delphissä string, ja SaveAsPdfAToStream täyttää jokaisen tyhjän optiokentän Info-dictionarystä ennen merkkien injektointia. Tämä sijoitus on Free Pascalilla kaventava muunnos ja RTL tekee sen kohdemerkkijonon codepagen kautta. LCL-ohjelmassa LazUTF8 on jo asettanut DefaultSystemCodePage-arvon muotoon CP_UTF8, joten kavennus tuottaa UTF-8:aa ja kaikki jatkossa sattuu toimimaan oikein. Tavallisessa konsoliohjelmassa sama kavennus osuu ANSI-codepageen, ja StringToUtf8 kopioi oktetit sitten muuttamatta mitään koska se oletti niiden olevan jo UTF-8:aa. Kuusi tallennussiltaa jakaa tämän muodon: SaveAsPdfAToStream, SaveAsPdfUaToStream, SaveAsPdfEToStream, SaveAsPdfXToStream, SaveAsPdfRToStream ja SaveAsPdfVTToStream, jokaisella omassa optiotietueessaan
// PDFium.pas: dokumentin accessor-metodit ovat aina UTF-16
// WString = WideString FPC:llä, = string (UnicodeString) Delphillä
function TPdf.GetTitle: WString;
// FPdfPdfa.pas: save-option record sisältää metadatan `string`-tyyppisenä
TPdfASaveOptions = record
Conformance: TPdfAConformance;
IccProfileData: TBytes;
Title: string; // UnicodeString Delphillä
// AnsiString + DefaultSystemCodePage FPC:llä
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 täyttää tyhjät kentät takaisin Info-dictionarystä
// Kavennus kirjoitetaan nyt näkyviin eikä jätetä implisiittiseksi:
if Eff.Title = '' then
Eff.Title := WStringToStr(GetTitle);
Kaikkien kuuden sillan ohjaaminen yhden WStringToStr-helperin läpi ei muuta RTL:n toimintaa, mutta sijoittaa muunnoksen kohtaan jossa lukija näkee sen, ja se poisti 92 implisiittisen muunnoksen varoitusta jotka olivat peittäneet juuri tämän luokan ongelmat. Tämä on peilikuva Delphin puolen korruptiosta jota käsitellään muistiinpanoissa Delphi- ja FPC-ristikääntäjien sudenkuopat PDFium-buildissä, jossa Delphin konkatenointi tuhoaa korkean tavun jonka Free Pascal säilyttää
Kolme Free Pascal -käyttäytymistä jotka päihittävät ilmeisen korjauksen
Ilmeinen korjaus on kutsua UTF8Encode ja jatkaa. FPC 3.2.2:ssa Delphi-tilassa se epäonnistuu kolmella tavalla ja jokainen epäonnistuminen on hiljainen
var
W: UnicodeString;
S: string;
U: UTF8String;
R: RawByteString;
Xmp: AnsiString;
begin
// Ansa 1: Delphi-tilassa UTF8String-muuttuja on tavallinen AnsiString,
// joten sijoitus transkoodaa oktetit suoraan takaisin isännän codepageen
U := UTF8Encode(W);
// Ansa 2: S on jo AnsiString, joten UTF8Encode ei tee lainkaan mitään
R := UTF8Encode(S); // ei dekoodausta, ei enkoodausta, ei virhettä
R := UTF8Encode(UnicodeString(S)); // tämä todella enkoodaa
// Ansa 3: konkatenointi yhtenäistää jokaisen operandinsa kohteen codepageen,
// eikä RawByteString-kohde ole poikkeus
Xmp := Xmp + R;
end;
Ensimmäinen ansa tarkoittaa että enkoodatun tuloksen täytyy pysyä siinä AnsiString- tai RawByteString-arvossa jossa se tuotettiin. Vie se matkalla ulos UTF8String-väliaikaisen kautta ja olet kumonnut työn. Toinen ansa piiloutuu pisimpään, koska UTF8Encode(S) kääntyy, toimii, palauttaa oikean pituisen arvon eikä tee minkäänlaista muunnosta kun argumentti on jo AnsiString; vain ensin UnicodeString-tyypiksi levennetty arvo saa kutsun dekoodaamaan mitään. Kolmas ansa selittää miksi oikea enkooderi voi silti tuottaa rikkinäisen dokumentin: BuildXmpBytes kokoaa paketin paikalliseen Xmp: AnsiString-muuttujaan ja Free Pascal muuntaa jokaisen konkatenoinnin operandin kohdemuuttujan codepageen, taittaen monen tavun sekvenssit matkalla takaisin yksitavuisiksi ANSI-tavuiksi
Mitä SetCodePage False-arvolla oikeastaan takaa?
SetCodePage(RawByteString(Result), DefaultSystemCodePage, False) uudelleenmerkitsee merkkijonon koskematta yhteenkään tavuun. Kolmas parametri on Convert; arvon False antaminen tarkoittaa "oleta payload jo kohdecodepageen ja muuta vain tunniste". Se on tarkoituksellinen valhe sisällöstä: oktetit ovat oikeasti UTF-8:aa, mutta niiden merkitseminen isännän codepageksi estää kolmannen ansan konkatenointia muuntamasta niitä. Ne liittyvät XMP-puskuriin raakatavuina ja tulevat toiselta puolelta ulos muuttumattomina
function StringToUtf8(const S: string): AnsiString;
begin
{$IFDEF UNICODE}
// Delphi: UTF8Encode tuottaa jo CP_UTF8-merkityt oktetit ja
// AnsiStringiin konkatenointi säilyttää ne
Result := AnsiString(UTF8Encode(S));
{$ELSE}
// FPC: levennä ensin, muuten UTF8Encode on AnsiString-argumentille no-op
Result := UTF8Encode(UnicodeString(S));
// Merkitse uudelleen ilman transkoodausta, jotta oktetit säilyvät
// XMP-paketteja ja PDF-merkkijono-objekteja kokoavissa ANSI-puskureissa
if Length(Result) > 0 then
SetCodePage(RawByteString(Result), DefaultSystemCodePage, False);
{$ENDIF}
end;
Raja kannattaa pitää selkeänä. Uudelleenmerkintä on vain FPC:tä varten eikä se ole yleinen lupa sekoittaa merkittyjä ja merkitsemättömiä merkkijonoja. Se toimii tässä koska alavirran kuluttajamalli on täsmälleen yksi: liitä AnsiString-muuttujaan ja kirjoita puskuri ulos tavuina. Kaikki joka yrittäisi tulkita uudelleenmerkityn arvon isännän codepage-tekstinä lukisi mojibakea, ja oikein lukisikin. Vastakkainen suunta käsitellään toisella tavalla ja identtisesti molemmissa kääntäjissä: merkitse tuleva puskuri CP_UTF8-arvolla käyttäen SetCodePage(..., False)-kutsua ja kutsu sitten UTF8ToString
Miksi regressiotestit kantoivat samaa ansaa?
Koska testi joka rakentaa odotetut tavut lähdekoodin literaalista testaa kääntäjää eikä kirjastoa. Pascal-lähdetiedostoon kirjoitettu vakio kuten #$C3#$A9 kantaa kyseisen tiedoston käännösaikaisen codepagen, ja kun se annetaan AnsiString-parametrille, RTL enkoodaa sen uudelleen, mikä on juuri testattavana oleva muunnos. Odotus täytyy rakentaa ajon aikana tavu tavulta ja verrata tavu tavulta, koska =-operaattori sovittaa kahden eri tunnisteella varustetun AnsiString-arvon codepaget ennen vertailua ja palauttaa iloisen false negativen
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; // testattava kavennus
Encoded := StringToUtf8(Narrowed);
finally
SetMultiByteConversionCodePage(Saved);
end;
// 'Caf' + U+00E9 UTF-8:na, rakennetaan ajon aikana jotta literaalia ei enkoodata uudelleen
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;
Testikehys itsessään on LCL-ohjelma, joten DefaultSystemCodePage on CP_UTF8 ja bugi on näkymätön kunnes testi vaihtaa sen. SetMultiByteConversionCodePage(1252) try..finally-lohkossa matkii tavallista konsoliympäristöä yhden testin ajan. End-to-end-tarkistus menee pidemmälle ja varmistaa molemmat suunnat: merkkien injektoinnin tuottaman XMP-paketin täytyy sisältää tavut $43 $61 $66 $C3 $A9 ja eikä se saa sisältää tavusarjaa $43 $61 $66 $E9, joten tuleva regressio joka palaa raakaan yksitavuiseen tulosteeseen epäonnistuu selvästi sen sijaan että tuottaisi tiedoston joka näyttää heksadumpissa vain uskottavalta. Jos käsittelet ei-latinalaista metadataa, sama leventämisen kuri ohjaa tapauksia artikkelissa emoji- ja CJK-tekstit jotka rikkovat WideChar-käsittelyn Delphissä
Missä muualla kavennus osuu?
XMP on näkyvä uhri, mutta jokainen saman koodikannan TBytes- ja string-sillan yhdistelmä oli yhtä altis. v3.103.1:ssä korjattiin kaksi muuta: Utf8BytesToString ja StringToUtf8Bytes unitissa FPdfProduction, jotka kierrättävät XFA datasets -paketin merkkijonon kautta jotta MergePdfXfaDatasets voi korvata sidotut arvot, sekä BytesToUtf8 unitissa FPdfTrustedList, joka dekoodaa eurooppalaisen trusted-listin XML:n poistettuaan tavujärjestysmerkin. Molemmat puskuroivat nyt datan RawByteString-muuttujaan, merkitsevät sen CP_UTF8:ksi ilman muunnosta ja dekoodaavat UTF8ToString-kutsulla. Yksi moduuli oli jo immuuni, ja syy kannattaa kopioida. XFDF-kirjoittaja määrittelee oman tekstityyppinsä XFDFString, joka ratkeaa FPC:n alla WideString-tyypiksi ja Delphin alla UnicodeString-tyypiksi, joten sen enkooderi ei näe lainkaan codepage-merkittyä AnsiString-arvoa. Se on rakenteellinen korjaus: pidä teksti UTF-16-tyypissä täsmälleen serialisointiin asti ja anna yhden kapean funktion omistaa muunnos tavuiksi. Kaikki tämän perheen bugit syntyivät siitä että string-kenttä istui muunnoksen keskellä putkea jonka toinen pää oli UTF-16 ja toinen oktetteja
Mitä omassa kahden kääntäjän PDF-koodissa kannattaa tarkistaa?
Jos toimitat Object Pascal -koodia joka toimii molemmilla kääntäjillä ja kirjoittaa metadataa standardinmukaiseen PDF:ään, neljä tarkistusta löytää suurimman osan tästä luokasta ennen validointia
- Greppaa
UTF8Encode-kutsut joiden argumentti onstring. FPC:llä kutsu on no-op ja se on yksittäinen eniten osumia tuottava tarkastettava rivi - Käsittele jokaista
UTF8String-muuttujaa epäilyttävänä Delphi-tilassa. Se on siellä tavallinenAnsiStringja koodattujen tavujen sijoittaminen siihen transkoodaa ne takaisin - Aja vähintään yksi regressio
SetMultiByteConversionCodePage-kutsun alla yksitavuisella codepagella. LCL-testikehys ajaaCP_UTF8-arvolla eikä koskaan toista tavallisen konsoliohjelman ympäristöä - Rakenna odotetut tavuvektorit ajon aikana ja vertaa niitä oktetti oktetilta. Lähdekoodiliteraalit ja
=kulkevat molemmat codepage-sovituksen kautta ja piilottavat etsimäsi vian
Mikään tässä ei ole eksoottista Free Pascal -nippelitietoa. Se on tavallinen kustannus kielelle joka säilytti tavupohjaisen merkkijonotyypin UTF-16-tyypin rinnalla, ja kääntäjät tekivät järkevät mutta erilaiset valinnat siitä kumpaa string tarkoittaa. Käytännön seuraus PDF-työssä on kapea ja terävä: IDE:ssä oikein näkyvä metadata voi saapua XMP-pakettiin virheellisenä UTF-8:na, eikä ISO 19005-1 6.7.2 välitä kumpi kääntäjä sen sinne laittoi. Jos rakennat arkistointiputkea, enkoodauskerros ansaitsee yhtä paljon huomiota kuin muu sen ympärillä oleva PDF/A-arkistovaatimustenmukaisuuden työnkulku. PDFium Delphi Component toimittaa nämä muunnokset osana kirjastoa, joten SaveAsPdfA ja sen viisi standardsisarusta emittoivat vaatimustenmukaista UTF-8-metadataa Delphillä, Lazarusilla ja tavallisilla Free Pascal -buildeilla ilman kutsujan codepage-määrityksiä. Täydellinen API-dokumentaatio ja nykyinen julkaisu löytyvät PDFium Delphi Component -tuotesivulta