Technisch artikel

PDF/A-preflightvalidatie in Delphi met PDFium VCL

Een archive-ingestiegate wees een batch "PDF/A-2b"-bestanden af die in elke viewer op het bureau prima openden. De leverancier zweerde dat ze conform waren. Dat waren ze niet: elk bestand droeg een JavaScript-action diep in de catalog, het soort ding dat een vluchtige blik nooit ziet en een echte PDF/A-validator zoals veraPDF meteen afkeurt. De kink in de kabel is dat niemand een Java-toolchain op een Delphi-batchservice wilde schroeven om per bestand één ja-of-nee-vraag te beantwoorden. Dat is de kloof die ValidatePdfACompliance in PDFium Component vult, en het loont om te begrijpen hoe die een oordeel vormt zonder ooit een content stream volledig te parsen

Waarom PDFium zelf dit niet kan beantwoorden

Het eerste om eerlijk over te zijn: de meegeleverde pdfium.dll heeft helemaal geen PDF/A-mogelijkheid. Er is geen ConvertToPDFA, geen OutputIntent-writer, geen XMP-API in de publieke surface. Alles rond PDF/A in deze bibliotheek, zowel schrijven als controleren, leeft in pure Pascal in FPdfPdfa.pas en werkt via byte-level parsing plus incremental update. Dus wanneer je de validator aanroept, vraag je niet iets aan Chromium's renderer. Je draait een Pascal token scanner over de structurele bytes van het bestand

De publieke API is bewust klein. Eén functie leest een stream vanaf positie 0 en geeft een record terug:

IsCompliant encodeert de regel die in een gate telt: een bestand slaagt alleen wanneer een echt conformiteitsniveau is gedetecteerd en de issueset leeg is. Een parse die slaagt maar geen pdfaid-marker vindt, komt uit op pacNone, en dat is expliciet geen pass. Dit is hetzelfde punt dat de batch preflight report CLI van buitenaf maakt: een lege lijst met bevindingen op een niet-herkend bestand is geen schoon gezondheidsbewijs

Stream bodies verwijderen vóór elke tokenscan

Hier zit het belangrijkste implementatiedetail, en het is het makkelijkst verkeerd te doen als je zelf een scanner schrijft. De detector vindt schendingen door naar afgebakende naamtokens te zoeken, dingen als /JavaScript, /LZWDecode, /BM. Als je de ruwe bestandbytes scant, kunnen de ingesloten binaire stream bodies, gecomprimeerde afbeeldingen, ICC-profielen en fontprograms willekeurig bytepatronen bevatten die op zulke tokens lijken. Dan rapporteer je /AA of /3D als "gevonden" omdat drie bytes in een JPEG toevallig dat woord vormden. Dat is een fabriek voor false positives

De oplossing is PdfStructureBytes: het loopt door het bestand en vult de bytes tussen elk stream- en endstream-keyword met spaties, terwijl de dictionarystructuur intact blijft. Pas daarna loopt de scan. Elke naamtokencontrole in de validator werkt op deze gestript kopie. Als je één idee uit dit artikel meeneemt, neem dan deze. Dezelfde discipline wordt gespiegeld in de PDF/UA-validator, die zijn eigen kopie van dezelfde routine bijhoudt omdat de twee standaarden onafhankelijk evolueren

De 29 issues en wat elk ervan betekent

TPdfAValidationIssue is een gedocumenteerd contract. De ordinalen zijn vastgezet omdat DUnitX-tests, de demo's en de rapportlaag ervan afhankelijk zijn, dus nieuwe bevindingen worden alleen nog aan het einde toegevoegd. Sinds v1.63.0 zijn er 29 members. Ze vallen in een paar families:

  • Metadata en identiteit: pvaiMissingXmpMetadata, pvaiMissingPdfAIdentifier, pvaiMissingTrailerId (ISO 19005-1 6.1.3), pvaiMissingXmpDates
  • Kleur en output: pvaiMissingOutputIntent, pvaiMissingIccProfile, en pvaiMixedDeviceColorSpaces wanneer zowel DeviceRGB als DeviceCMYK voorkomt (6.2.3.3)
  • Harde verboden voor elk deel: pvaiEncryptionPresent (een /Encrypt-dictionary is simpelweg verboden), pvaiJavaScriptPresent, pvaiForbiddenAction, pvaiAdditionalActions, pvaiLzwUsed, pvaiXfaPresent, pvaiNeedAppearancesTrue, pvaiForbiddenAnnotation
  • Fonts: pvaiFontNotEmbedded en de strengere pvaiUnembeddedFont, plus pvaiUnicodeMappingMissing voor een Level U-claim zonder /ToUnicode
  • Tagging: pvaiLevelAStructureMissing wanneer een conformance=A-claim geen getagde structuur heeft

De zes nieuwste members, toegevoegd op ordinalen 24 t/m 29, dekken de subtiele gevallen waar reviewers echt op struikelen: pvaiTrappedTrue (een /Trapped /True in het Info-dictionary, een valse vriend omdat de waarde False of Unknown moet zijn), pvaiForbiddenActionSubtype (Sound of Movie gebruikt als action, niet alleen als annotation), pvaiTransparentColorSpace (een niet-Normal blend mode of een /CA//ca die niet gelijk is aan 1.0), pvaiAnnotationDictViolation, pvaiUnembeddedFont en pvaiMixedDeviceColorSpaces

Part-bewuste gating: A-1 is streng, A-2 en A-3 zijn soepeler

PDF/A is geen enkel regelboek. Drie dingen die PDF/A-1 verbiedt, zijn vanaf PDF/A-2 expliciet toegestaan: transparantie, een /Transparency-groep of een actieve /SMask, 6.4; optionele content, /OCProperties, 6.1.13; en ingebedde bestanden, /EmbeddedFiles of /EF, 6.1.11. Een naïeve validator die al deze drie voor elk bestand afkeurt, wijst perfect geldige PDF/A-2-documenten massaal af

Daarom leest de validator het partnummer uit de pdfaid-marker via PdfAPartOf en zet hij die controles achter PartNo = 1. De blend-mode- en annotation-alpha-controles voor de nieuwe transparantie-issues zijn op dezelfde manier alleen voor deel 1:

Een conservatieve default verdient nog een vermelding: als er helemaal geen pdfaid-marker is, wordt het part als 1 behandeld, dus als het strengste. De redenering is dat een niet-geïdentificeerd bestand aan de strengste regels moet worden gehouden in plaats van erdoorheen te worden gewuifd. JavaScript, forbidden actions, LZW, XFA, NeedAppearances, forbidden annotations en unembedded fonts blijven voor elk part verboden, dus die checks zitten nooit achter de gate

Object streams uitbreiden zodat niets zich verstopt

PDF 1.5 introduceerde de cross-reference stream en de object stream (/Type /ObjStm), en die creëren een blinde vlek voor een naïeve bytescanner. Een catalog, een OutputIntent, een actiondictionary, alles wat zelf geen stream is, kan als Flate-gecomprimeerd object in een ObjStm zitten. Scan je de ruwe structuur, dan zie je daar niets van, en rapporteer je een schoon bestand dat allesbehalve schoon is

PdfExpandObjectStreams dicht die kloof. Voor er ook maar één check draait, doet de validator Data := PdfExpandObjectStreams(Data). De routine vindt elke ObjStm, leest de header /N en /First om de ingesloten objectnummers en offsets te krijgen, inflate de body met PdfInflate (de RTL zlib, System.ZLib op Delphi en zstream op FPC), en plakt elk ingesloten object als een gewoon N 0 obj ... endobj aan het einde van een kopie van de bytes. De bestaande tokenchecks vinden die objecten daarna zonder dat hun logica hoeft te veranderen

Twee voorwaarden houden dit netjes in plaats van fragiel. Streamobjecten, metadata, ICC-profielen en fontprogramma's kunnen niet in een object stream leven, alleen niet-stream dictionaries, dus expansie behandelt alleen dictionaries en de aangeplakte objecten dragen geen stream-keyword dat de body-stripping-pass zou verstoren. En omdat de aangeplakte content achter %%EOF komt te staan, vindt de reverse search vanaf startxref nog steeds de originele trailer. De cross-reference-stream trailer zelf was al eerder afgehandeld, in v1.49.3, door Root, Size en ID rechtstreeks uit de plaintext xref-stream dictionary te lezen, een onderwerp dat in het begeleidende stuk over validating object and cross-reference streams wordt uitgewerkt; het object-streamwerk hoefde alleen de inflate-stap toe te voegen, zonder type-2 xref-entries te decoderen of een PNG predictor af te wikkelen

De eerlijke grenzen van een byte-level checker

Dit is een preflighttool, geen gecertificeerde validator, en de grenzen zijn reëel. Font embedding is een telheuristiek, en dat goed krijgen kostte een correctie die het waard is om te kennen. De oorspronkelijke check gebruikte PdfCountName('/FontDescriptor'), maar elk font levert twee /FontDescriptor-tokens op, één verwijzing vanuit de fontdictionary en één /Type in het descriptorobject zelf, dus de telling was 2N tegen N embedded programs en de test was altijd waar. De fix is PdfCountDescriptorRefs, die alleen de referentievorm /FontDescriptor N G R telt, één per font, en pvaiUnembeddedFont pas verhoogt wanneer embedded programs echt in de minderheid zijn:

Zelfs gecorrigeerd blijft het grof: een gemengd document waar elk descriptor toevallig een FontFile heeft, kan nog steeds een individuele niet-conforme font laten ontsnappen. Het uitbreiden van object streams heeft ook een bekend neveneffect, het maakt de standaard-14 default resources zichtbaar die een AcroForm /DR draagt, zoals /Helv, en de heuristiek meldt die netjes als niet embedded, ook al laat veraPDF ze door omdat ze nooit echt voor rendering worden gebruikt. Controlesen op content-streamoperatorniveau (6.2.10) vallen helemaal buiten scope, omdat die een volledige contentparser vereisen in plaats van een bytescan. Behandel de validator als een snelle, dependency-free eerste poort die de schendingen vangt die markerinjectie niet kan oplossen, en reserveer een volledige validator voor de uiteindelijke certificering

Dit is de controlekant van het verhaal. De complementaire schrijfkant, waar SaveAsPdfA de XMP, OutputIntent en sRGB ICC-profiel injecteert en een Level A-verzoek dat geen getagde structuur heeft eerlijk degradeert, bouwt op dezelfde byte-level mechaniek. Beide kanten worden meegeleverd in de PDFium Component voor Delphi, één VCL-pakket bovenop een pure-Pascal PDF/A-implementatie zonder externe runtime om te installeren

function ValidatePdfACompliance(Source: TStream): TPdfAValidationResult;

type
  TPdfAValidationResult = record
    Conformance: TPdfAConformance;        // pacUnknown, pacNone, pac1b, pac2u, ...
    Issues: TPdfAValidationIssues;        // a set of TPdfAValidationIssue
    function IsCompliant: Boolean;        // True only when level <> unknown/none
  end;                                    // AND Issues is empty
if PartNo = 1 then
begin
  if PdfHasName(Struct, '/BM') then
    if not PdfHasBMNormal(Struct) then          // only /Normal or /Compatible allowed
      Include(Result.Issues, pvaiTransparentColorSpace);
  if PdfHasCaNotOne(Struct, '/CA') or PdfHasCaNotOne(Struct, '/ca') then
    Include(Result.Issues, pvaiTransparentColorSpace);
end;
K := PdfCountDescriptorRefs(Struct);                 // one per font dict
Emb := PdfCountName(Struct, '/FontFile')
     + PdfCountName(Struct, '/FontFile2')
     + PdfCountName(Struct, '/FontFile3');
if (K > 0) and (Emb < K) then
  Include(Result.Issues, pvaiUnembeddedFont);