Betölt egy PDF-et, amelynek könyvjelző paneljén a „Gépjárműadó” feliratnak kellene szerepelnie, de helyette a „Gépjármñadó” szörnyűség jelenik meg. Vagy ami még rosszabb, az egész panel üres, mert az olvasó elutasította az értelmezhetetlen bájtokat. Ez a klasszikus kódolási probléma, és a PDF-vázfák (outline trees) esetében ez a probléma még mélyebb, mint a fájl többi részében. A PDF-címek nem osztoznak a tartalomfolyamok (content streams) betűtípus-leképezéseiben; ezek nyers bájtsorozatok egy szótárban, és a generátorok hosszú éveken át figyelmen kívül hagyták a kódolási szabályokat. Ha egy könyvtár mereven csak a szabványt követi, akkor óhatatlanul olyan fájlokkal találkozik majd, amelyeket a megjelenítő szétrombolna
A PDFlibPas egy natív Object Pascal PDF-motor Delphihez és C++Builderhez. Eredeti vázfa-olvasója követte a PDF specifikációt: kereste a UTF-16 Byte Order Mark (BOM) jelet, és ennek hiányában a PDFDocEncoding-ra hagyatkozott. Ez a megközelítés azonban elbukott a valós fájlok jelentős részénél, amelyek nyers UTF-8 vagy helyi ANSI kódolású bájtokat írtak le BOM nélkül. A v3.81.0 verzióban bevezetett új kódolási modell egy konfigurálható fallback állapotgépet tartalmaz, amely megpróbálja megmenteni a sérült könyvjelző címeket. Ez az a történet, ahol a fallback valóban megmenti a helyzetet
A kódolások közötti feszültség
A PDF 1.7 specifikáció (§7.9.2.2) szerint a PDF-fájlban lévő szöveges karakterlánc (text string) csak két formátum egyike lehet:
- Egy UTF-16BE kódolású bájtsorozat, amelynek a
$FE $FFByte Order Mark (BOM) bájtokkal kell kezdődnie - A PDFDocEncoding kódolású bájtsorozat, amely egy kiterjesztett Latin-1-szerű kódlap, és nem rendelkezik BOM jelleggel. Ez a legkisebb közös nevező
Nincs UTF-8 a specifikációban a szöveges karakterláncokhoz. A valóságban azonban sok PDF-generátor egyszerűen UTF-8 bájtokat ír a könyvjelző címekbe BOM nélkül, abban a hitben, hogy a modern olvasók majd kitalálják. Mások a helyi Windows kódlapot (ANSI, pl. 1250 Kelet-Európa vagy 1252 Nyugat-Európa) használják. Ha egy merev értelmező találkozik ezekkel a bájtokkal, és nincs ott a $FE $FF, akkor kénytelen a PDFDocEncoding szabályai szerint dekódolni őket. A UTF-8-as é betű ($C3 $A9) a PDFDocEncoding szerint é karakterré válik, ami szemetet jelent a képernyőn
Az automatikus fallback működése
A PDFlibPas úgy oldja fel ezt, hogy nem hiszi el vakon a kódolás hiányát. Ha a cím nem UTF-16 (mert hiányzik a BOM), és a hívó engedélyezte az UTF-8 feldolgozást, az értelmező először érvényes UTF-8-ként próbálja meg értelmezni a bájtokat. Ha a UTF-8 dekódolás sikeres, a hívó választhat a helyreállítási viselkedések közül:
type
TPDFlibOutlineTitleEncoding = (
etAuto, // UTF-16 -> UTF-8 -> ANSI fallback (ajánlott)
etUtf8, // kényszerített UTF-8
etAnsi // kényszerített ANSI / PDFDocEncoding
);
A kulcs az etAuto profil. Nem csak megpróbálja az UTF-8-at, hanem teszteli is azt. Az UTF-8 bájtsorozatoknak szigorú matematikai szabályokat kell követniük a több-bájtos szekvenciák felépítésénél. Egy olyan szekvencia, mint a $C3 $A9 érvényes UTF-8; de a helyi kódlapon írt magányos $E9 (ami az é betű a 1250-es kódlapon) érvénytelen UTF-8-ként fog elbukni, mert a bevezető bájt után hiányzik a megfelelő folytató bájt. Az értelmező ezt a validációt használja arra, hogy eldöntse, UTF-8 formátumban kódolta-e a szalagcímet, mielőtt eldöntené, hogyan tovább
Ez a folyamat a következő állapotgépen halad át, anélkül, hogy végtelen ciklusba esne:
- Ha a bájtok a
$FE $FF-el kezdődnek, akkor a dekóder UTF-16BE-ként értelmezi őket. Ez a szabványos út, és nincs fallback - Ha nincs BOM, a dekóder megvizsgálja, hogy a bájtsorozat érvényes UTF-8-e. Ha igen, és a beállítás megengedi, UTF-8-ként dekódolja. Ez menti meg a modern, specifikáción kívüli generátorok kimenetét
- Ha a bájtsorozat érvénytelen UTF-8 (például magányos magas bites bájtokat tartalmaz), a dekóder elveti az UTF-8 útvonalat, és a beállított ANSI kódlap (vagy a PDFDocEncoding) segítségével értelmezi a bájtokat. Ez menti meg a régi, helyi kódlappal írt fájlokat
A validáció a nyers bájtokon fut le, még mielőtt a memóriában karakterlánc-konverzió történne. Ha a bájtok érvénytelenek a UTF-8-hoz, a dekóder meg sem próbálja azt, hanem azonnal ANSI-ként kezeli. Ez a szétválasztás a lényeg: ha megpróbálnánk a UTF-8 konverziót a hibás bájtokon, a Windows API helyettesítő karakterekkel (kérdőjelekkel) töltené fel a szöveget, és az eredeti ANSI bájtok elvesznének a visszaállítási lépés előtt. A konverzió előtti validáció megőrzi a nyers bájtokat a sikeres fallback útvonal számára, így ez a legkisebb kockázatú döntés
Egy részletet tisztázni kell: az etUtf8 és etAnsi beállítások kikapcsolják ezt az állapotgépet. Ha a kódolást kifejezetten etUtf8-ra állítja, az értelmező UTF-8-ként próbálkozik, és ha a bájtok hibásak, a konverzió elbukik vagy sérült karaktereket ad vissza. Hasonlóan az etAnsi kényszeríti a fallback nélküli Latin-1/ANSI értelmezést. Ez a választás biztosítja a determinisztikus viselkedést, amikor előre tudja a dokumentumok forrását, és a könyvtár nem próbálja meg kitalálni az Ön szándékát
A kódolási mód beállítása
Az automatikus fallback alapértelmezetten be van kapcsolva a könyvtárban (etAuto), de az alapértelmezett beállítások megváltoztatása egyszerű:
var
Pdf: TPDFlib;
begin
Pdf := TPDFlib.Create(nil);
try
Pdf.LoadFromFile('legacy.pdf', '');
// A könyvjelzők címének UTF-8 kódolásúként történő értelmezése
Pdf.OutlineTitleEncoding := etUtf8;
// Ha a könyvjelző címe érvénytelen UTF-8, az ANSI fallback használata
Pdf.OutlineTitleEncoding := etAuto;
// ... könyvjelzők bejárása ...
finally
Pdf.Free;
end;
end;
Ha a kódolást etAuto-ra állítja, a GetOutlineTitle hívása előtt a könyvtár az automatikus fallback állapotgépet futtatja le, és a visszatérő könyvjelzőt érvényes UTF-8-ként fogja értelmezni
A könyvjelzők olvasása
A lekérdező (getter) kódja megbízhatóan egyszerű:
// a TPDFOutlineTree.GetOutlineTitle metóduson belül
function TPDFOutlineTree.GetOutlineTitle(OutlineID: Integer): WideString;
var
RawTitle: AnsiString;
begin
RawTitle := GetRawTitleBytes(OutlineID);
Result := DecodeTitle(RawTitle, FTitleEncoding);
end;
A GetRawTitleBytes egyszerűen kiemeli a szótárból a nyers bájtokat, a DecodeTitle pedig végrehajtja a fent bemutatott háromlépéses kódolás-visszaállítási folyamatot, és az egészet egy tiszta Delphi WideString-gé alakítja
A Unicode karakterhossz csapda
A kódolások kezelése során a leggyakoribb csapda a karakterhossz és a bájthossz összetévesztése. A PDF-ben a könyvjelző cím hossza a nyers bájtok száma, de a Delphi WideString vagy string típusoknál a karakterek száma nem egyezik meg a bájtok számával. Ha a könyvjelző bejárása során a bájthosszt használja karakterindexelésre, egy off-by-one (eggyel eltolt) hibába fog belefutni
A megvalósítás során a validációt a PLIsValidUtf8 függvény végzi, amely a UTF-8 szabvány szerinti bájtszerkezetet ellenőrzi:
function PLIsValidUtf8(const S: AnsiString): Boolean;
var
I, Len, Need: Integer;
B: Byte;
begin
Result := True;
Len := Length(S);
I := 1;
while I <= Len do
begin
B := Byte(S[I]);
if B < $80 then Need := 0
else if (B and $E0) = $C0 then Need := 1
else if (B and $F0) = $E0 then Need := 2
else if (B and $F8) = $F0 then Need := 3
else Exit(False); // érvénytelen kezdőbájt
Inc(I);
while Need > 0 do
begin
if I > Len then Exit(False); // csonka szekvencia
B := Byte(S[I]);
if (B and $C0) <> $80 then Exit(False); // érvénytelen folytatóbájt
Dec(Need);
Inc(I);
end;
end;
end;
Ez a szigorú ellenőrzés biztosítja, hogy a helyi kódlap magas ASCII karakterei ne keveredhessenek össze a UTF-8 szekvenciákkal. A kódlap-alapú fallback így csak akkor lép életbe, ha a bemenet bizonyíthatóan nem UTF-8. Sokan elkövetik azt a hibát, hogy a fenti ellenőrzés helyett egyszerűen a Windows MultiByteToWideChar API-ra támaszkodnak a MB_ERR_INVALID_CHARS jelzővel. A probléma az, hogy a régebbi Windows verziókon a GDI/API nem érvényesítette szigorúan ezt a jelzőt, így a hibás bájtok elkerülik a figyelmet. A saját kézzel írt validáció hordozható, determinisztikus és független a Windows operációs rendszer verziójától. Azért van ott, hogy felhívja a figyelmet a hibára, nem pedig azért, hogy csendben lenyelje azt
Ez a validáció azt is jelenti, hogy a etAuto mód gyakorlatilag zéró plusz költséggel jár a jól formázott dokumentumok esetében. A UTF-16BE fájlokat a BOM alapján azonnal kiszűri a rendszer, így a validáció meg sem próbálkozik azokon. Az UTF-8 dokumentumok pedig az első ellenőrzésen átmennek, így a fallback ág soha nem hajtódik végre. A teljesítmény így megmarad a tiszta UTF-8 szintjén, miközben a legacy dokumentumok is olvashatók maradnak, anélkül, hogy a megjelenítést egyáltalán elrontaná
A fallback határai
Bár a fallback állapotgép nagyon hatékony, vannak határai, amelyeket érdemes szem előtt tartani. A fallback csak a kódolást képes helyreállítani; nem képes javítani a fizikai bájtsérülést vagy a csonka adatokat. Ha a PDF generátor eleve hiányosan írta ki a könyvjelző bájtokat a szótárba, az adatok egy része végleg elveszett, és ez az archiválási feladatok és nem pedig a fordítási lépés feladata
Az itt bemutatott kódolási és fallback beállítások a PDFlibPas Delphi PDF Library termékoldala nyújt teljes képet