Technikai cikk

PDF-könyvjelzők címének kódolása és ANSI fallback Delphiben

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 $FF Byte 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:

  1. 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
  2. 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
  3. 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