Műszaki cikk

PDF/A előellenőrzés Delphiben a PDFium VCL eszközzel

Egy archívum beviteli kapuja visszautasított egy köteg „PDF/A-2b” fájlt, amely az asztalon minden megjelenítőben rendben megnyílt. A beszállító esküdött rá, hogy szabványosak. Nem voltak azok: mindegyik egy JavaScript-műveletet rejtett a katalógusban, azt a fajtát, amelyet egy futó pillantás soha nem vesz észre, egy teljes PDF/A-ellenőrző, például a veraPDF viszont egy szempillantás alatt megjelöl. A bökkenő az volt, hogy senki nem akart Java eszközláncot csavarozni egy Delphi kötegelt szolgáltatásra pusztán azért, hogy fájlonként egyetlen igen-nem kérdésre válaszoljon. Ezt a hézagot tölti be a PDFium Component ValidatePdfACompliance függvénye, és érdemes megérteni, hogyan jut ítéletre anélkül, hogy valaha is teljesen elemezne egy tartalomstreamet

PDF/A előellenőrzési folyamat Delphihez a PDFium Component eszközzel: a nyers PDF-ből kiürülnek a streamtörzsek, az objektumstreamek kibontásra kerülnek, egy Pascal tokenvizsgálat pedig a szerkezeti bájtok fölött kitölt egy TPdfAValidationResult rekordot, amelynek IsCompliant kapuja felismert szintet és üres hibahalmazt követel
A ValidatePdfACompliance soha nem elemez tartalomstreamet: kiürített streamtörzsek és kibontott objektumstreamek mellett a szerkezeti bájtokat vizsgálja, az átmenéshez pedig felismert megfelelőségi szint és üres hibahalmaz kell

Miért nem tud erre maga a PDFium válaszolni

Az első dolog, amiben őszintének kell lenni: a mellékelt pdfium.dll egyáltalán nem tud PDF/A-t. Nincs ConvertToPDFA, nincs OutputIntent-író, nincs XMP API a nyilvános felületen. Ebben a könyvtárban a PDF/A minden része – az író és az ellenőrző oldal is – tiszta Pascalban él az FPdfPdfa.pas unitban, és bájtszintű elemzéssel meg növekményes frissítéssel dolgozik. Amikor tehát meghívjuk az ellenőrzőt, nem a Chromium megjelenítőmotorjától kérdezünk semmit. Egy Pascal tokenvizsgálót futtatunk a fájl szerkezeti bájtjai fölött

A nyilvános API szándékosan kicsi. Egyetlen függvény olvas egy streamet a 0. pozíciótól, és egy rekordot ad vissza:

function ValidatePdfACompliance(Source: TStream): TPdfAValidationResult;

type
  TPdfAValidationResult = record
    Conformance: TPdfAConformance;        // pacUnknown, pacNone, pac1b, pac2u, ...
    Issues: TPdfAValidationIssues;        // TPdfAValidationIssue értékek halmaza
    function IsCompliant: Boolean;        // csak akkor True, ha a szint <> unknown/none
  end;                                    // ÉS az Issues üres

Az IsCompliant azt a szabályt kódolja, amely egy kapunál számít: egy fájl csak akkor megy át, ha valódi megfelelőségi szintet ismertünk fel, és a hibahalmaz üres. Az a sikeres elemzés, amely nem talál pdfaid jelölőt, pacNone értékre oldódik fel, ami kifejezetten nem átmenés. Ugyanezt mondja kívülről a kötegelt előellenőrzési jelentés parancssori eszköze is: egy fel nem ismert fájlon az üres megállapításlista nem tiszta egészségügyi bizonyítvány

A streamtörzsek kiürítése minden tokenvizsgálat előtt

Itt jön a legfontosabb megvalósítási részlet, egyben az, amelyet a legkönnyebb elrontani, ha saját vizsgálót írunk. A felismerő úgy talál szabálysértéseket, hogy határolt névtokenekre keres, például a /JavaScript, /LZWDecode vagy /BM tokenre. Ha a nyers fájlbájtokat vizsgáljuk, a beágyazott bináris streamtörzsek – tömörített képek, ICC-profilok, betűkészletprogramok – véletlenszerűen tartalmazni fognak olyan bájtsorozatokat, amelyek ezekre a tokenekre hasonlítanak. Azt fogjuk jelenteni, hogy az /AA vagy a /3D „megvan”, mert egy JPEG belsejében három bájt véletlenül ezt betűzte ki. Ez tévesriasztás-gyár

A megoldás a PdfStructureBytes: végigjárja a fájlt, és minden stream és endstream kulcsszó közötti bájtot szóközre ürít, a szótárszerkezetet érintetlenül hagyva. A vizsgálat csak ezután fut le. Az ellenőrző minden névtoken-próbája ezen a kiürített másolaton dolgozik. Ha egyetlen gondolatot viszünk el ebből a cikkből, ez legyen az. Ugyanez a fegyelem tükröződik a PDF/UA ellenőrzőben, amely saját másolatot tart az eljárásból, mert a két szabvány egymástól függetlenül fejlődik

A 29 hibakód és jelentésük

A TPdfAValidationIssue dokumentált szerződés. A sorszámok be vannak fagyasztva, mert a DUnitX tesztek, a demók és a jelentésréteg mind rájuk támaszkodnak, ezért az új megállapítások mindig csak a végére kerülnek. A v1.63.0 verzió szerint 29 tagja van. Néhány családba sorolhatók:

  • Metaadat és azonosság: pvaiMissingXmpMetadata, pvaiMissingPdfAIdentifier, pvaiMissingTrailerId (ISO 19005-1 6.1.3), pvaiMissingXmpDates
  • Szín és kimenet: pvaiMissingOutputIntent, pvaiMissingIccProfile, valamint pvaiMixedDeviceColorSpaces, ha DeviceRGB és DeviceCMYK is előfordul (6.2.3.3)
  • Minden részre érvényes kemény tiltások: pvaiEncryptionPresent (az /Encrypt szótár egyenesen tilos), pvaiJavaScriptPresent, pvaiForbiddenAction, pvaiAdditionalActions, pvaiLzwUsed, pvaiXfaPresent, pvaiNeedAppearancesTrue, pvaiForbiddenAnnotation
  • Betűkészletek: pvaiFontNotEmbedded és a szigorúbb pvaiUnembeddedFont, valamint pvaiUnicodeMappingMissing arra az esetre, ha egy U szintű állítás mögött nincs /ToUnicode
  • Címkézés: pvaiLevelAStructureMissing, amikor egy conformance=A állítás mögött nincs címkézett szerkezet

A hat legújabb tag, amely a 24–29. sorszámra került, azokat a finom eseteket fedi le, amelyekbe a lektorok valóban belebotlanak: pvaiTrappedTrue (egy /Trapped /True az Info szótárban, ez „hamis barát”, mert az értéknek False vagy Unknown lehet csak), pvaiForbiddenActionSubtype (Sound vagy Movie műveletként használva, nem csupán jegyzetként), pvaiTransparentColorSpace (nem Normal keverési mód vagy egy 1.0 értéktől eltérő /CA illetve /ca), pvaiAnnotationDictViolation, pvaiUnembeddedFont és pvaiMixedDeviceColorSpaces

A PDFium Component Delphi PDF/A ellenőrzőjének 29 TPdfAValidationIssue kódja csoportosítva: metaadat és azonosság, szín és kimenet, kemény tiltások, betűkészletek, címkézés, valamint a hat legújabb megállapítás, például a pvaiTrappedTrue és a pvaiTransparentColorSpace
A 29 hibakód öt családra oszlik, plusz a hat legújabb tagra, a kemény tiltások pedig – titkosítás, JavaScript, LZW – a PDF/A minden részére vonatkoznak

Résztudatos kapuzás: az A-1 szigorú, az A-2 és A-3 enged

A PDF/A nem egyetlen szabálykönyv. Három olyan dolog, amelyet a PDF/A-1 tilt, a PDF/A-2 verziótól kezdve kifejezetten megengedett: az átlátszóság (egy /Transparency csoport vagy egy aktív /SMask, 6.4), a választható tartalom (/OCProperties, 6.1.13) és a beágyazott fájlok (/EmbeddedFiles vagy /EF, 6.1.11). Az a naiv ellenőrző, amely mindhármat minden fájlnál megjelöli, tömegével utasít vissza tökéletesen érvényes PDF/A-2 dokumentumokat

Résztudatos PDF/A kapuzás Delphiben: a bejelentett pdfaid rész egy PartNo = 1 kaput táplál, amely csak a PDF/A-1 esetén kapcsolja be az átlátszóság, a választható tartalom és a beágyazott fájlok ellenőrzését, míg a JavaScript, az LZW, az XFA és a be nem ágyazott betűkészletek minden részre tiltottak maradnak
A résztudatos kapuzás beolvassa a pdfaid részszámát, és az átlátszóság, a választható tartalom és a beágyazott fájlok ellenőrzését csak a PDF/A-1 esetén alkalmazza, a jelölő nélküli fájlokat pedig a legszigorúbb szabályokhoz méri

Az ellenőrző ezért a PdfAPartOf segítségével olvassa ki a részszámot a pdfaid jelölőből, és ezeket az ellenőrzéseket a PartNo = 1 feltétel mögé kapuzza. Az új átlátszósági hibákhoz tartozó keverésimód- és jegyzetáttetszőség-ellenőrzések hasonlóképpen csak az 1. részre vonatkoznak:

if PartNo = 1 then
begin
  if PdfHasName(Struct, '/BM') then
    if not PdfHasBMNormal(Struct) then          // csak a /Normal vagy /Compatible megengedett
      Include(Result.Issues, pvaiTransparentColorSpace);
  if PdfHasCaNotOne(Struct, '/CA') or PdfHasCaNotOne(Struct, '/ca') then
    Include(Result.Issues, pvaiTransparentColorSpace);
end;

Egy óvatos alapértelmezés említést érdemel: ha egyáltalán nincs pdfaid jelölő, a rész 1-nek számít, vagyis a legszigorúbbnak. Az érvelés az, hogy egy azonosítatlan fájlt inkább a legszorosabb szabályokhoz kell mérni, mint átinteni. A JavaScript, a tiltott műveletek, az LZW, az XFA, a NeedAppearances, a tiltott jegyzetek és a be nem ágyazott betűkészletek minden részre tiltottak maradnak, így ezek az ellenőrzések soha nem kerülnek a kapu mögé

Objektumstreamek kibontása, hogy semmi ne rejtőzhessen el

A PDF 1.5 bevezette a kereszthivatkozási streamet és az objektumstreamet (/Type /ObjStm), ezek pedig vakfoltot okoznak egy naiv bájtvizsgálónak. Egy katalógus, egy OutputIntent, egy műveleti szótár – bármi, ami maga nem stream – Flate-tömörítve ülhet egy ObjStm belsejében. Ha a nyers szerkezetet vizsgáljuk, ebből semmit nem látunk, majd tisztának jelentünk egy fájlt, amely minden, csak nem az

A PdfExpandObjectStreams zárja be ezt a rést. Bármely ellenőrzés lefutása előtt az ellenőrző elvégzi a Data := PdfExpandObjectStreams(Data) lépést. Az eljárás megkeres minden ObjStm objektumot, beolvassa a /N és /First fejlécet a benne foglalt objektumszámokhoz és eltolásokhoz, kibontja a törzset a PdfInflate segítségével (az RTL zlib, Delphin a System.ZLib, FPC-n a zstream), majd minden benne foglalt objektumot szokásos N 0 obj ... endobj alakban a bájtok egy másolatának végére fűz. A meglévő tokenellenőrzések ezután a logikájuk bármiféle változtatása nélkül megtalálják ezeket az objektumokat

Két megkötés teszi ezt tisztává, nem törékennyé. A streamobjektumok – a Metadata, az ICC-profil és a betűkészletprogramok – nem élhetnek objektumstreamben, csak a nem stream típusú szótárak, ezért a kibontás mindig csak szótárakkal dolgozik, a hozzáfűzött objektumok pedig nem hordoznak stream kulcsszót, amely megzavarná a törzskiürítő menetet. És mivel a hozzáfűzött tartalom a %%EOF jelölés után landol, a startxref felől visszafelé induló keresés továbbra is megtalálja az eredeti trailert. Magát a kereszthivatkozási stream trailerét már korábban, a v1.49.3 verzióban kezeltük úgy, hogy a Root, a Size és az ID értékét közvetlenül a nyílt szövegű xref-stream szótárból olvassuk – ezt a témát az objektum- és kereszthivatkozási streamek ellenőrzéséről szóló testvércikk járja körbe; az objektumstreamek kezeléséhez már csak a kibontási lépést kellett hozzátenni, 2-es típusú xref-bejegyzések dekódolása és PNG-prediktor visszafejtése nélkül

Egy bájtszintű ellenőrző őszinte korlátai

Ez előellenőrző eszköz, nem hitelesített ellenőrző, és a határai valósak. A betűkészletek beágyazottsága számláló heurisztika, és a helyes működéséhez egy említésre méltó javításra volt szükség. Az eredeti ellenőrzés a PdfCountName('/FontDescriptor') hívást használta, de minden betűkészlet két /FontDescriptor tokent ad hozzá: egy hivatkozást a betűkészlet-szótárból és egy /Type bejegyzést magában a leíróobjektumban, így a szám N beágyazott program mellett 2N lett, a próba pedig mindig igaz volt. A javítás a PdfCountDescriptorRefs, amely csak a /FontDescriptor N G R hivatkozási alakot számolja, betűkészletenként egyet, és csak akkor emeli be a pvaiUnembeddedFont hibát, ha a beágyazott programok száma valóban kevesebb:

K := PdfCountDescriptorRefs(Struct);                 // betűkészlet-szótáranként egy
Emb := PdfCountName(Struct, '/FontFile')
     + PdfCountName(Struct, '/FontFile2')
     + PdfCountName(Struct, '/FontFile3');
if (K > 0) and (Emb < K) then
  Include(Result.Issues, pvaiUnembeddedFont);

Még javítva is durva: egy vegyes dokumentumban, ahol minden leíróhoz történetesen tartozik valamilyen FontFile, egy-egy nem szabványos betűkészlet még mindig átcsúszhat. Az objektumstreamek kibontásának is van ismert mellékhatása: felfedi azokat a szabványos 14 alapértelmezett erőforrást, amelyeket egy AcroForm /DR bejegyzése hordoz, például a /Helv készletet, a heurisztika pedig kötelességtudóan be nem ágyazottként jelenti őket, jóllehet a veraPDF átengedi ezeket, mert valójában soha nem használatosak megjelenítésre. A tartalomstreamek operátorszintű ellenőrzései (6.2.10) teljesen kívül esnek a hatókörön, mert bájtvizsgálat helyett teljes tartalomelemzést igényelnének. Kezeljük az ellenőrzőt gyors, függőségmentes első kapuként, amely elkapja azokat a szabálysértéseket, amelyeket jelölő beszúrásával nem lehet elfedni, a végleges hitelesítésre pedig tartsunk fenn egy teljes ellenőrzőt

Ez a történet ellenőrző fele. A kiegészítő írói oldal, ahol a SaveAsPdfA beszúrja az XMP-t, az OutputIntent bejegyzést és az sRGB ICC-profilt, és őszintén visszaminősít egy A szintű kérést, amely mögött nincs címkézett szerkezet, ugyanerre a bájtszintű gépezetre épül. Mindkét fél a Delphihez készült PDFium Component részeként érkezik: egyetlen VCL csomag egy tiszta Pascal PDF/A megvalósítás fölött, telepítendő külső futtatókörnyezet nélkül