Odborný článok

PDF/A Preflight Validation in Delphi with PDFium VCL

Brána ingestie archívu odmietla dávku súborov „PDF/A-2b“, hoci sa na každom prehliadači na stole otvárali bez problémov. Dodávateľ prisahal, že sú konformné. Neboli: každý z nich niesol JavaScript action ukrytý v katalógu, presne ten typ veci, ktorý letmý pohľad nikdy neodhalí a plný validátor PDF/A, napríklad veraPDF, odhalí okamžite. Háčik bol v tom, že nikto nechcel do dávkovej služby v Delphi pridávať java toolchain len kvôli jednej binárnej otázke na súbor. Túto medzeru vypĺňa ValidatePdfACompliance v PDFium Component a stojí za to pochopiť, ako dochádza k verdiktu bez toho, aby sa vôbec kompletne parsoval content stream

Preflight pipeline PDF/A pre Delphi s PDFium Component: surové PDF sa olúpe o telá tokov, toky objektov sa nafúknu a Pascal token sken nad bajtmi štruktúry naplní jeden TPdfAValidationResult, ktorej brána IsCompliant vyžaduje rozpoznanú úroveň a prázdnu množinu problémov
ValidatePdfACompliance nikdy neparsuje content stream: skenuje štrukturálne bajty s vyprázdnenými telami streamov a rozbalenými object streams a prechod vyžaduje detekovanú úroveň zhody plus prázdnu sadu issue

Prečo na to samotné PDFium nestačí

Prvá vec, ktorú treba priznať: pribalené pdfium.dll nemá žiadnu schopnosť pracovať s PDF/A. Vo verejnom rozhraní neexistuje ConvertToPDFA, žiadny zapisovač OutputIntent, žiadne XMP API. Každá časť PDF/A v tejto knižnici, strana zápisu aj strana kontroly, žije v čistom Pascale v FPdfPdfa.pas a funguje cez parsovanie na úrovni bajtov plus inkrementálnu aktualizáciu. Keď teda voláte validátor, nepýtate sa nič rendereru z Chromiumu. Spúšťate pascalovský token scanner nad štrukturálnymi bajtmi súboru

Verejné API je zámerne malé. Jedna funkcia prečíta stream od pozície 0 a vráti záznam:

function ValidatePdfACompliance(Source: TStream): TPdfAValidationResult;

type
  TPdfAValidationResult = record
    Conformance: TPdfAConformance;        // pacUnknown, pacNone, pac1b, pac2u, ...
    Issues: TPdfAValidationIssues;        // množina TPdfAValidationIssue
    function IsCompliant: Boolean;        // True iba keď level <> unknown/none
  end;                                    // A Issues je prázdna

IsCompliant kóduje pravidlo, na ktorom v gate záleží: súbor prejde iba vtedy, keď bola zistená skutočná úroveň zhody a množina problémov je prázdna. Parsovanie, ktoré uspeje, ale nenájde marker pdfaid, sa vyhodnotí ako pacNone, čo výslovne nie je úspech. Na to isté z vonkajšej strany upozorňuje aj dávkový nástroj preflight report CLI: prázdny zoznam nálezov pri nerozpoznanom súbore nie je čistý účet zdravia

Odstránenie tiel streamov pred akýmkoľvek token scanom

Toto je najdôležitejší detail implementácie a zároveň ten, ktorý sa najľahšie pokazí, ak si vlastný scanner píšete sami. Detektor nachádza porušenia vyhľadávaním ohraničených name tokenov, vecí ako /JavaScript, /LZWDecode, /BM. Ak skenujete surové bajty súboru, vložené binárne telá streamov, komprimované obrázky, ICC profily a font programy budú náhodne obsahovať sekvencie bajtov, ktoré vyzerajú ako tieto tokeny. Nahlásite, že sa „našlo“ /AA alebo /3D, pretože tri bajty vo vnútri JPEGu ich náhodou vyhláskovali. Vznikne továreň na false positive

Oprava je PdfStructureBytes: prejde súbor a vynuluje bajty medzi každým stream a endstream na medzery, pričom štruktúru slovníkov nechá nedotknutú. Až potom sa spustí sken. Každá kontrola name tokenov vo validátore pracuje nad touto odizolovanou kópiou. Ak si z tohto článku odnesiete jedinú myšlienku, nech je to táto. Tá istá disciplína sa zrkadlí aj vo validátore PDF/UA, ktorý si túto rutinu drží vo vlastnej kópii, pretože oba štandardy sa vyvíjajú nezávisle

29 problémov a čo každý z nich znamená

TPdfAValidationIssue je zdokumentovaný kontrakt. Ordinaly sú zmrazené, pretože od nich závisia DUnitX testy, demá aj report vrstva, takže nové nálezy sa vždy len pripájajú na koniec. K verzii v1.63.0 má enum 29 členov. Padajú do niekoľkých rodín:

  • Metadáta a identita: pvaiMissingXmpMetadata, pvaiMissingPdfAIdentifier, pvaiMissingTrailerId (ISO 19005-1 6.1.3), pvaiMissingXmpDates
  • Farba a výstup: pvaiMissingOutputIntent, pvaiMissingIccProfile a pvaiMixedDeviceColorSpaces, keď sa objavia súčasne DeviceRGB aj DeviceCMYK (6.2.3.3)
  • Tvrdé zákazy pre každú časť: pvaiEncryptionPresent (slovník /Encrypt je úplne zakázaný), pvaiJavaScriptPresent, pvaiForbiddenAction, pvaiAdditionalActions, pvaiLzwUsed, pvaiXfaPresent, pvaiNeedAppearancesTrue, pvaiForbiddenAnnotation
  • Fonty: pvaiFontNotEmbedded a prísnejší pvaiUnembeddedFont, plus pvaiUnicodeMappingMissing pre nárok na Level U bez /ToUnicode
  • Tagovanie: pvaiLevelAStructureMissing, keď nárok na zhodu=A nemá tagovanú štruktúru

Šesť najnovších členov, pridaných na ordinaloch 24 až 29, pokrýva jemné prípady, na ktorých recenzenti reálne zakopávajú: pvaiTrappedTrue (/Trapped /True v slovníku Info, „falošný priateľ“, pretože hodnota musí byť False alebo Unknown), pvaiForbiddenActionSubtype (Sound alebo Movie použité ako action, nielen ako anotácia), pvaiTransparentColorSpace (iný ako Normal blend mode alebo /CA//ca nerovné 1,0), pvaiAnnotationDictViolation, pvaiUnembeddedFont a pvaiMixedDeviceColorSpaces

Diagram 29 kódov TPdfAValidationIssue vo validátore PDF/A PDFium Component pre Delphi, zoskupených do metadát a identity, farby a výstupu, tvrdých zákazov, písem, značkovania a šiestich najnovších nálezov ako pvaiTrappedTrue a pvaiTransparentColorSpace
29 issue kódov sa delí na päť rodín plus šesť najnovších členov, s tvrdými zákazmi ako šifrovanie, JavaScript a LZW, ktoré sa vzťahujú na každú časť PDF/A

Gating podľa časti: A-1 je prísne, A-2 a A-3 povoľujú viac

PDF/A nie je jedna kniha pravidiel. Tri veci, ktoré PDF/A-1 zakazuje, sú od PDF/A-2 výslovne povolené: transparentnosť (skupina /Transparency alebo aktívna /SMask, 6.4), optional content (/OCProperties, 6.1.13) a vložené súbory (/EmbeddedFiles alebo /EF, 6.1.11). Naivný validátor, ktorý všetky tri veci označí ako porušenie pri každom súbore, bude hromadne odmietať úplne platné dokumenty PDF/A-2

Preto validátor číta číslo časti z markera pdfaid cez PdfAPartOf a tieto kontroly púšťa len za podmienky PartNo = 1. Kontroly blend módu a alfa kanálu anotácií pre nové problémy s transparentnosťou sú takisto obmedzené iba na časť 1:

if PartNo = 1 then
begin
  if PdfHasName(Struct, '/BM') then
    if not PdfHasBMNormal(Struct) then          // povolené je len /Normal alebo /Compatible
      Include(Result.Issues, pvaiTransparentColorSpace);
  if PdfHasCaNotOne(Struct, '/CA') or PdfHasCaNotOne(Struct, '/ca') then
    Include(Result.Issues, pvaiTransparentColorSpace);
end;

Jedno konzervatívne predvolené správanie stojí za zmienku: keď marker pdfaid chýba úplne, časť sa považuje za 1, teda za najprísnejšiu. Úvaha je jednoduchá: neidentifikovaný súbor má byť držaný na najprísnejších pravidlách, nie mávnutý ďalej. JavaScript, zakázané akcie, LZW, XFA, NeedAppearances, zakázané anotácie a nevložené fonty zostávajú zakázané pre všetky časti, preto tieto kontroly nikdy nesedia za gate

Rozbalenie object streamov, aby sa v nich nič neschovalo

PDF 1.5 zaviedlo cross-reference stream aj object stream (/Type /ObjStm) a tie vytvárajú slepé miesto pre naivný byte scanner. Katalóg, OutputIntent, action dictionary, skrátka čokoľvek, čo samo nie je stream, môže byť Flate-komprimované vo vnútri ObjStm. Ak skenujete surovú štruktúru, neuvidíte z toho nič a nahlásite čistý súbor, ktorý čistý vôbec nie je

PdfExpandObjectStreams túto medzeru uzatvára. Pred spustením akejkoľvek kontroly validátor vykoná Data := PdfExpandObjectStreams(Data). Rutina nájde každý ObjStm, prečíta jeho hlavičku /N a /First, aby získala čísla a offsety obsiahnutých objektov, telo rozbalí pomocou PdfInflate (RTL zlib, System.ZLib na Delphi a zstream na FPC) a každý obsiahnutý objekt pripojí ako obyčajný N 0 obj ... endobj na koniec kópie bajtov. Existujúce tokenové kontroly potom tieto objekty nájdu bez jedinej zmeny logiky

Dve obmedzenia z toho robia čisté riešenie namiesto krehkého hacku. Stream objekty, Metadata, ICC profil aj font programy, nemôžu žiť vo vnútri object streamu, žiť tam môžu iba slovníky bez streamu, takže expanzia vždy pracuje iba so slovníkmi a pripojené objekty nenesú žiadne kľúčové slovo stream, ktoré by narušilo krok odstraňovania tiel. A pretože pripojený obsah pristane až za %%EOF, spätné vyhľadávanie od startxref stále nájde pôvodný trailer. Samotný trailer cross-reference streamu bol vyriešený už skôr, vo v1.49.3, čítaním Root, Size a ID priamo zo slovníka xref-streamu v čistom texte, čo je téma rozobraná v sesterskom článku o validácii object a cross-reference streamov. Táto téma bola vyriešená už skôr, takže object-stream práca potrebovala pridať len krok inflate bez potreby dekódovať type-2 xref entries alebo rozmotávať PNG predictor

Zatarasenie PDF/A uvedomujúce si časť v Delphi: deklarovaná časť pdfaid kŕmi bránu PartNo = 1 púšťajúcu kontroly transparentnosti, voliteľného obsahu a vložených súborov len pre PDF/A-1, zatiaľ čo JavaScript, LZW, XFA a nevložené písma zostávajú zakázané pre každú časť
Gating vnímajúci časť číta číslo časti pdfaid a aplikuje kontroly transparentnosti, voliteľného obsahu a vloženého súboru len na PDF/A-1, pričom súbory bez značky drží k najprísnejším pravidlám

Poctivé limity checkeru na úrovni bajtov

Toto je preflight nástroj, nie certifikovaný validátor, a jeho hranice sú skutočné. Detekcia vložených fontov je heuristika založená na počtoch a aj tá si vyžiadala opravu, ktorú sa oplatí poznať. Pôvodná kontrola používala PdfCountName('/FontDescriptor'), ale každý font prispieva dvomi tokenmi /FontDescriptor, jednou referenciou zo slovníka fontu a jedným /Type v samotnom objekte descriptora, takže počet bol 2N oproti N vloženým programom a test bol vždy pravdivý. Opravou je PdfCountDescriptorRefs, ktorá počíta iba formu referencie /FontDescriptor N G R, jednu na font, a vyvolá pvaiUnembeddedFont iba vtedy, keď je vložených programov fontov skutočne menej:

K := PdfCountDescriptorRefs(Struct);                 // jeden na slovník fontu
Emb := PdfCountName(Struct, '/FontFile')
     + PdfCountName(Struct, '/FontFile2')
     + PdfCountName(Struct, '/FontFile3');
if (K > 0) and (Emb < K) then
  Include(Result.Issues, pvaiUnembeddedFont);

Aj po oprave zostáva hrubá: zmiešaný dokument, v ktorom má každý descriptor nejaký FontFile, môže stále prepustiť jednotlivý nezhodný font. Expanzia object streamov má aj známy vedľajší efekt: odkryje štandard-14 default resources, ktoré nesie /DR AcroFormu, ako napríklad /Helv, a heuristika ich poctivo nahlási ako nevložené, hoci veraPDF ich prepustí, pretože sa nikdy reálne nepoužijú na vykreslenie. Kontroly na úrovni operátorov content streamu (6.2.10) sú zámerne úplne mimo rozsah, pretože by si vyžiadali plné parsovanie obsahu namiesto byte scanu. Berte validátor ako rýchlu prvú bránu bez externých závislostí, ktorá zachytí tie porušenia, ktoré injekcia markerov nevie opraviť, a plný validátor si nechajte na finálnu certifikáciu

Toto je kontrolná polovica príbehu. Komplementárna zapisovacia strana, kde SaveAsPdfA vkladá XMP, OutputIntent a ICC profil sRGB a čestne znižuje nárok na Level A bez tagovanej štruktúry, stavia na tej istej bytovej infraštruktúre. Obe polovice sú súčasťou PDFium Component pre Delphi, jediného balíka VCL nad čisto pascalovskou implementáciou PDF/A bez akéhokoľvek externého runtime na inštaláciu