Technisch artikel

PDF/VCR-1-templatesjablonen in Delphi met PDFlibPas

PDFlibPas implementeert PDF/VCR-1 als een getagd structuurprofiel: de vervangingsdefinitie leeft onder StructTreeRoot als een directe kindertak, waarbij een template-wortel /O /GTS_Template draagt om de veldset te declareren en elke placeholder /O /GTS_Replacement draagt om één dataveld te binden. Niets ervan is een dictionary op paginaniveau

Die ene zin is het deel waar de meeste implementaties de fout ingaan, en de fout kost meer dan een mislukte validatie. Een variable-data-job waarvan het vervangingsplan verkeerd wordt gelezen crasht niet. Hij print, op volume, met het verkeerde veld in het verkeerde vakje, en de eerste die het merkt is de klant met vijftigduizend mailings in handen

Waar de vervangingsdefinitie werkelijk woont

PDF/VCR-1 plaatst zijn vervangingsmodel in de logische structuurhiërarchie die ISO 32000-1 §14.7 beschrijft, niet in een privé-pagina dictionary. De template-wortel is een structuurelement dat direct aan StructTreeRoot hangt, herkenbaar aan een attribute owner van GTS_Template, en hij declareert de hele veldvocabulaire op één plek via GTS_Fields plus een optionele GTS_Pages die het veld noemt dat de paginaselectie aanstuurt

Placeholders zijn structuurelementen die binnen die wortel genest zitten, met attribute owner GTS_Replacement. Elk draagt een GTS_Generator (die PDFlibPas op PassThrough vereist), een GTS_Data-waarde die exact één gedeclareerd veld noemt en een optionele GTS_BBox. De binding aan de pagina is ook geen pagina-annotatie: het element wijst via /Pg naar zijn pagina en via /K naar de marked-content-sequence. De keuze voor structuur boven een pagina-dictionary brengt dan een consequentie mee die makkelijk te stellen en makkelijk te onderschatten is: de structuurboom is een algemene graaf die vanuit de catalog bereikt wordt, dus alles wat het profiel consumeert moet een boomwandelaar zijn met alle gevaren van dien, in plaats van een dictionary-lezer. Heb je nog niet met de structuurboom gewerkt, dan liggen de mechanieken uiteen in getagde PDF-structuur en toegankelijkheid

Waar PDFlibPas het PDF/VCR-1-vervangingsmodel plaatst: de template-wortel hangt direct aan StructTreeRoot met een attribute owner van GTS_Template die GTS_Fields en de optionele GTS_Pages declareert, terwijl elke geneste placeholder GTS_Replacement draagt, een GTS_Generator van PassThrough, één GTS_Data-veld, een optionele GTS_BBox, en via /Pg naar zijn pagina en via /K naar zijn content wijst
De vervangingsdefinitie woont in de logische structuurhiërarchie in plaats van in een pagina-dictionary, wat elke consumer tot een boomwandelaar maakt en het element verandert in een graafknoop die een aanvaller kan vormen

Waarom laat het checken van de eerste top-level tak slechte bestanden door?

Omdat StructTreeRoot veel directe kinderen kan hebben, en een validator die stopt na het lokaliseren van de template-wortel de rest nooit ziet. Dat is precies het falen dat PDFlibPas is gebouwd om te vermijden, en het produceert exact het soort false pass dat erger is dan helemaal geen check: een bestand dat conformant rapporteert en een tweede template-wortel bevat, of een placeholder die buiten de template-tak zit waar geen enkele consumer hem honoreert

Daarom draait ValidatePDFVCRStructure in PDFlibPas twee passes over dezelfde boom. De eerste pass somt elk direct kind van StructTreeRoot op en telt de exemplaren die GTS_Template dragen; iets anders dan exact één wordt geweigerd voordat er één placeholder is gelezen. De tweede pass daalt af in alle top-level takken, niet alleen de template-tak, en wijst twee dingen tegelijk af: een genest element dat GTS_Template draagt maar niet de wortel is, en elk GTS_Replacement-element dat buiten de template-tak wordt gevonden

De traversal is begrensd op 64 niveaus diepte en 1.000.000 knopen, en hij registreert het object- en generatienummer van elk bezocht element zodat een cyclus of een herhaalde referentie wordt gerapporteerd in plaats van gevolgd. Die getallen zijn geen prestatieafstemming. Een structuurboom is een objectgraaf die een aanvaller controleert, en een validator die zonder plafond recursieert is een stack overflow met een bestandsextensie

Twee-pass validatie van ValidatePDFVCRStructure in PDFlibPas: pass één telt de directe kinderen van StructTreeRoot die GTS_Template dragen en weigert iets anders dan exact één, pass twee daalt in elke top-level tak af om een geneste template-wortel en elk GTS_Replacement buiten de template-tak af te wijzen, begrensd op 64 niveaus en één miljoen knopen
Een validator die stopt na het lokaliseren van de eerste template-wortel ziet een tweede nooit, dus de wandeling bestrijkt alle top-level takken en registreert objectnummers zodat een cyclus wordt gerapporteerd in plaats van gevolgd

Een template auteuren, en de constraints die de API weigert te buigen

PDFlibPas stelt auteuren bloot via een bracketing-API die de structuurregels bij het schrijven afdwingt in plaats van ze bij het saven te ontdekken. SetPDFVCRMode schakelt het document naar het profiel, wat intern de PDF/X-4-basismode van ISO 15930-7 vereist en de mark-info-flag aanzet, want een PDF/VCR-1-bestand zonder marked content heeft niets waar een placeholder naar kan wijzen. Van daaruit neemt BeginPDFVCRTemplate de veldlijst en het paginaselectieveld, en hij is streng op manieren die de moeite waard zijn om te kennen voordat je je dataschema ontwerpt. Veldnamen gaan door PDFVCRValidFieldName, dat alleen ASCII-letters, cijfers, underscore, koppelteken en punt accepteert, want dat zijn de tekens die zonder escaping-ambiguïteit in een PDF-name serialiseren. PDFVCRNormalizeFieldList houdt de lijst hoofdlettergevoelig en wijst duplicaten botweg af, en het paginaselectieveld moet zelf in de gedeclareerde lijst voorkomen

var
  Lib: TPDFlib;
  Diag: WideString;
begin
  Lib := TPDFlib.Create;
  try
    Lib.NewDocument;

    if Lib.SetPDFVCRMode(1) <> 1 then
      raise Exception.Create('PDF/VCR mode was refused');

    Lib.NewPage;
    if Lib.BeginPDFVCRTemplate('Salutation,FullName,AccountNo',
                               'AccountNo') <> 1 then
      raise Exception.Create('Field list rejected');

    // Eén placeholder bindt exact één gedeclareerd veld.
    Lib.BeginPDFVCRPlaceholder('FullName', 72, 690, 320, 712);
    Lib.DrawText(72, 694, 'SAMPLE NAME');
    Lib.EndPDFVCRPlaceholder;

    Lib.EndPDFVCRTemplate;

    Diag := Lib.GetPDFVCRDiagnostics;
    if Diag <> '' then
    begin
      Writeln('PDF/VCR blockers: ', Diag);
      Exit;
    end;

    Lib.SaveToFile('campaign-template.pdf');
  finally
    Lib.Free;
  end;
end;

Let op wat de bracketing je oplevert. BeginPDFVCRPlaceholder weigert te openen tenzij een template actief is en de tag-stack exact één niveau diep is, zo garandeert PDFlibPas dat placeholders directe kinderen van de template-wortel zijn en nooit in elkaar nesten. Coördinaten worden ook gevalideerd, met afwijzing van NaN, oneindig en geïnverteerde rechthoeken, dus een slechte bounding box faalt bij de aanroep in plaats van in een preflight-rapport een week later

Waarom heeft een placeholder zijn eigen MCID-serialisatiepad nodig?

Omdat PDF/VCR-1 wil dat /K een kaal integer is, en dat is niet hoe een algemene tagged-PDF-writer de link tussen een structuurelement en zijn content serialiseert. Onder ISO 32000-1 §14.6 refereert een structuurelement marked content normaal gesproken ofwel via een direct MCID-integer, wanneer het element zijn pagina al via /Pg identificeert, ofwel via een marked-content-referentie-dictionary dat de pagina expliciet noemt. Een library die voor veel doeleinden getagde output emitteert neigt naar de dictionary-vorm te standaardiseren omdat die beter over pagina's heen composeert

PDFlibPas koos hier de smalle optie in plaats van de algemene. SetCurrentStructElemDirectMCIDKid flagt het huidige structuurelement, en alleen elementen die die flag dragen serialiseren /K als een direct MCID met de pagina op /Pg; al het andere houdt het bestaande MCR-dictionary-pad onaangeroerd. Dit is een bewuste afweging en het soort dat je hardop moet benoemen: een profiel toevoegen moet de bytes niet veranderen die elk ander getagd document al produceert, dus het profiel krijgt een eigen pad in plaats van een globale schakelaar. Elk bestaand getagd bestand houdt zijn vorige serialisatie, en het regressieoppervlak blijft zo groot als het nieuwe feature

Eén traversal, twee consumers

PDFlibPas leidt het vervangingsmanifest en het compliance-verdict af uit dezelfde twee-pass wandeling, en daarom mag je het manifest als jobplan vertrouwen. De traversal vult tijdens het valideren een TPDFVCRManifestData-record: Fields uit de template-wortel, PageSelectionField wanneer er één is gedeclareerd, en een Placeholders-array van TPDFVCRPlaceholderInfo-entries met FieldName, PageNumber, MCID, HasBBox en BBox. Het compliance-ingangspunt is CheckCompliancePDFVCR(InputStream, Password, Options, ResultList), en het draait zijn checks in afhankelijkheidsvolgorde in plaats van allemaal tegelijk: eerst de volledige PDF/X-check, dan GTS_PDFXVersion tegen PDF/X-4, dan GTS_PDFVCRVersion gelezen uit de http://www.npes.org/pdfvcr/ns/id/-namespace, en pas daarna de structuurwandeling. Options als 1 doorgeven laat hem bij het eerste probleem stoppen, wat je wilt in een batch-gate; 0 doorgeven verzamelt alles, wat je wilt in een rapport

Eén traversal, twee consumers in PDFlibPas: dezelfde begrensde wandeling vult TPDFVCRManifestData met Fields, PageSelectionField en een Placeholders-array van TPDFVCRPlaceholderInfo-entries, terwijl CheckCompliancePDFVCR de volledige PDF/X-check, GTS_PDFXVersion, GTS_PDFVCRVersion uit de npes.org-namespace en pas daarna de structuurwandeling draait, en alleen 100 teruggeeft wanneer het bestand kwalificeert
Het manifest is een contract, dus BuildPDFVCRManifest geeft alleen JSON terug wanneer de issue-lijst leeg terugkomt en rapporteert anders fout 608, zodat een niet-conformant bestand zich niet als betrouwbaar jobplan kan voordoen
// Een ontvangen template verifiëren in plaats van een zelf geproduceerde
var
  Issues: TStringList;
  Stream: TFileStream;
  I: Integer;
begin
  Issues := TStringList.Create;
  Stream := TFileStream.Create('supplier-template.pdf',
                               fmOpenRead or fmShareDenyWrite);
  try
    if CheckCompliancePDFVCR(Stream, '', 0, Issues) <> 100 then
      for I := 0 to Issues.Count - 1 do
        Writeln('PDF/VCR: ', Issues[I]);
  finally
    Stream.Free;
    Issues.Free;
  end;
end;

Het manifest is een contract, dus het bestaat alleen voor conformante bestanden

BuildPDFVCRManifest geeft alleen JSON terug wanneer de issue-lijst leeg terugkomt, en anders een lege string. Dat is geen defensief coderen om het coderen. Een manifest wordt geconsumeerd door een compositie-engine die er echte output uit gaat sturen, en een gedeeltelijke veldlijst teruggeven die uit een bestand met twee template-wortels is gehaald nodigt de engine uit een niet-conformante structuur als een betrouwbaar vervangingsplan te behandelen. Via de document-API wikkelt GetPDFVCRManifest(InputFileName, Password) dezelfde aanroep en zet hij de laatste errorcode op PDFLIB_ERROR_PDFVCR_COMPLIANCE (608) wanneer het bestand niet kwalificeert, zodat een mislukte extractie en een lege template van elkaar te onderscheiden zijn. De JSON rapporteert fields, pageSelectionField als string of null, en een placeholders-array waarvan de entries field, page, mcid en ofwel een vier-getallige bbox ofwel null geven

var
  Lib: TPDFlib;
  Manifest: WideString;
begin
  Lib := TPDFlib.Create;
  try
    Manifest := Lib.GetPDFVCRManifest('campaign-template.pdf', '');
    if Manifest = '' then
      // 608 betekent dat het bestand niet PDF/VCR-1-conformant is,
      // 401 betekent dat het bestand niet kon worden geopend
      Writeln('No manifest, error ', Lib.LastErrorCode)
    else
      Writeln(Manifest);
  finally
    Lib.Free;
  end;
end;

Eén detail om mee te nemen naar je consumer: de page-waarde volgt de library-brede zero-based pagina-conventie, niet de one-based nummering die een mens in een viewer ziet. Die door elkaar halen is een stille off-by-one die content op het verkeerde vel laat landen, en geen enkele validator vangt hem omdat beide getallen structureel valide zijn

Waar de grenzen liggen

PDF/VCR-1-ondersteuning in PDFlibPas dekt template-auteuren, de save-gate, standalone preflight en manifestextractie. Het voert de vervanging niet uit. Data in placeholders substitueren en de gepersonaliseerde instanties produceren is de compositiestap, en die woont bewust buiten de library, want het manifest is precies de interface die een compositie-engine onafhankelijk laat schrijven. De save-gate weigert ook liever dan te repareren. Anders dan bij profielen waar een verkeerde annotatie-flag stilletjes genormaliseerd kan worden, is elke PDF/VCR-1-constraint die PDFVCRReadyForSave handhaaft er één waar gokken zou veranderen wat het document betekent: een template-wortel verzinnen, of kiezen welk veld een ongebonden placeholder hoort, produceert een bestand dat valideert en liegt. Dezelfde repair-versus-refuse-lijn wordt voor een ander profiel besproken in de PDF/E-1 authormode en bounded preflight, en de workflow om meerdere conformance-checks over één document te draaien staat in de compliance- en signing-workbench

Auteuren, de begrensde structuurwandeling, de save-gate en de manifest-extractor zitten allemaal in de PDFlibPas Delphi PDF-library, wat betekent dat een template onder het profiel geproduceerd en daarna via een apart codepad geverifieerd kan worden dat de writer-state nooit zag, en die scheiding is de enige reden dat een conformance-claim iets waard is