Artykuł techniczny

Placeholdery szablonów PDF/VCR-1 w Delphi z PDFlibPas

PDFlibPas implementuje PDF/VCR-1 jako profil struktury tagowanej: definicja zamiany żyje pod StructTreeRoot jako bezpośrednia gałąź-dziecko, gdzie korzeń szablonu niesie /O /GTS_Template, żeby zadeklarować zestaw pól, a każdy placeholder niesie /O /GTS_Replacement, żeby związać jedno pole danych. Nic w tym nie jest słownikiem na poziomie strony

To pojedyncze zdanie to część, którą większość implementacji psuje, a spudłowanie kosztuje więcej niż nieudana walidacja. Zadanie variable-data z błędnie odczytanym planem zamiany nie crashuje. Drukuje się, na wielką skalę, ze złym polem w złym polu tekstowym, a pierwszą osobą, która to zauważy, jest klient trzymający pięćdziesiąt tysięcy mailerów

Gdzie faktycznie żyje definicja zamiany

PDF/VCR-1 umieszcza swój model zamiany w hierarchii struktury logicznej opisanej w ISO 32000-1 §14.7, nie w prywatnym słowniku strony. Korzeń szablonu to element struktury zwisający bezpośrednio z StructTreeRoot, wyróżniony właścicielem atrybutu GTS_Template, i deklaruje całe słownictwo pól w jednym miejscu przez GTS_Fields plus opcjonalne GTS_Pages nazywające pole sterujące wyborem strony

Placeholdery to elementy struktury zagnieżdżone wewnątrz tego korzenia, z właścicielem atrybutu GTS_Replacement. Każdy niesie GTS_Generator (który PDFlibPas wymaga, by był PassThrough), wartość GTS_Data nazywającą dokładnie jedno zadeklarowane pole oraz opcjonalne GTS_BBox. Powiązanie ze stroną też nie jest adnotacją strony: element wskazuje swoją stronę przez /Pg, a sekwencję marked-content przez /K. Wybór struktury zamiast słownika strony niesie konsekwencję łatwą do wypowiedzenia i łatwą do niedoszacowania: drzewo struktury to ogólny graf osiągalny z katalogu, więc wszystko, co konsumuje profil, musi być tree walkerem ze wszystkimi wynikającymi z tego zagrożeniami, a nie czytnikiem słownika. Jeśli nie pracowałeś wcześniej z drzewem struktury, mechanika jest rozłożona w strukturze tagowanej PDF i dostępności

Gdzie PDFlibPas umieszcza model zamiany PDF/VCR-1: korzeń szablonu wisi bezpośrednio przy StructTreeRoot z właścicielem atrybutu GTS_Template deklarującym GTS_Fields i opcjonalne GTS_Pages, podczas gdy każdy zagnieżdżony placeholder niesie GTS_Replacement, GTS_Generator równy PassThrough, jedno pole GTS_Data, opcjonalne GTS_BBox i wskazuje swoją stronę przez /Pg, a treść przez /K
Definicja zamiany żyje w hierarchii struktury logicznej, a nie w słowniku strony, co czyni każdego konsumenta tree walkerem i zamienia element w węzeł grafu, który atakujący może ukształtować

Dlaczego sprawdzenie tylko pierwszej gałęzi najwyższego poziomu przepuszcza złe pliki?

Bo StructTreeRoot może mieć wiele bezpośrednich dzieci, a walidator, który zatrzymuje się po zlokalizowaniu korzenia szablonu, nigdy nie widzi reszty. To konkretna porażka, której uniknięcie jest celem PDFlibPas, i produkuje dokładnie ten rodzaj fałszywego zaliczenia, który jest gorszy niż żadne sprawdzenie: plik raportowany jako zgodny, zawierający drugi korzeń szablonu albo placeholder siedzący poza gałęzią szablonu, gdzie żaden konsument go nie uhonoruje

Więc ValidatePDFVCRStructure w PDFlibPas przechodzi dwa przebiegi po tym samym drzewie. Pierwszy przebieg wylicza każde bezpośrednie dziecko StructTreeRoot i liczy te z GTS_Template; cokolwiek innego niż dokładnie jeden jest odrzucane, zanim przeczytany zostanie jakikolwiek placeholder. Drugi przebieg schodzi do wszystkich gałęzi najwyższego poziomu, nie tylko szablonowej, i odrzuca dwie rzeczy naraz: zagnieżdżony element z GTS_Template, który nie jest korzeniem, oraz każdy element GTS_Replacement znaleziony poza gałęzią szablonu

Przechodzenie jest ograniczone do 64 poziomów głębokości i 1 000 000 węzłów, a przy tym rejestruje numer obiektu i generacji każdego odwiedzanego elementu, tak że cykl albo powtórzone odwołanie jest raportowane, a nie podążane. Te liczby to nie strojenie wydajności. Drzewo struktury to graf obiektów kontrolowany przez atakującego, a walidator rekurencyjny bez sufitu to stack overflow z rozszerzeniem pliku

Dwuprzebiegowa walidacja ValidatePDFVCRStructure w PDFlibPas: przebieg pierwszy liczy bezpośrednie dzieci StructTreeRoot z GTS_Template i odrzuca wszystko inne niż dokładnie jeden, przebieg drugi schodzi do każdej gałęzi najwyższego poziomu, by odrzucić zagnieżdżony korzeń szablonu i każde GTS_Replacement poza gałęzią szablonu, ograniczony do 64 poziomów i miliona węzłów
Walidator, który zatrzymuje się po zlokalizowaniu pierwszego korzenia szablonu, nigdy nie zobaczy drugiego, więc przejście pokrywa wszystkie gałęzie najwyższego poziomu i rejestruje numery obiektów, tak że cykl jest raportowany, a nie podążany

Autorstwo szablonu i ograniczenia, których API nie zgina

PDFlibPas wystawia autorstwo przez API z nawiasowaniem, które egzekwuje reguły strukturalne w momencie zapisu, zamiast odkrywać je w momencie zapisu do pliku. SetPDFVCRMode przełącza dokument do profilu, co wewnętrznie wymaga bazowego trybu PDF/X-4 z ISO 15930-7 i włącza flagę mark-info, bo plik PDF/VCR-1 bez marked content nie ma niczego, na co placeholder mógłby wskazywać. Stamtąd BeginPDFVCRTemplate przyjmuje listę pól i pole wyboru strony, a jest restrykcyjna na sposoby, które warto znać, zanim zaprojektujesz swój schemat danych. Nazwy pól przechodzą przez PDFVCRValidFieldName, która przyjmuje tylko litery ASCII, cyfry, podkreślnik, łącznik i kropkę, bo to znaki serializujące się do nazwy PDF bez dwuznaczności escapowania. PDFVCRNormalizeFieldList trzyma listę z zachowaniem wielkości liter i odrzuca duplikaty wprost, a pole wyboru strony musi samo pojawić się na zadeklarowanej liście

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');

    // Jeden placeholder wiąże dokładnie jedno zadeklarowane pole.
    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;

Zauważ, co daje ci to nawiasowanie. BeginPDFVCRPlaceholder odmawia otwarcia, dopóki szablon nie jest aktywny, a stos tagów ma dokładnie jeden poziom głębokości — tak PDFlibPas gwarantuje, że placeholdery są bezpośrednimi dziećmi korzenia szablonu i nigdy nie zagnieżdżają się w sobie nawzajem. Współrzędne też są walidowane, odrzucając NaN, nieskończoność i odwrócone prostokąty, więc zły bounding box pada przy wywołaniu, a nie w raporcie preflight tydzień później

Dlaczego placeholder potrzebuje własnej ścieżki serializacji MCID?

Bo PDF/VCR-1 chce, żeby /K było gołym integerem, a tak ogólny writer tagowanego PDF nie serializuje powiązania między elementem struktury a jego treścią. W myśl ISO 32000-1 §14.6 element struktury normalnie odwołuje się do marked content albo przez bezpośredni integer MCID, gdy element już identyfikuje swoją stronę przez /Pg, albo przez słownik-odwołanie do marked content nazywający stronę jawnie. Biblioteka emitująca tagowane wyjście do wielu celów ma tendencję do ujednolicenia formy słownikowej, bo lepiej komponuje się między stronami

PDFlibPas wybrał tu opcję wąską zamiast ogólnej. SetCurrentStructElemDirectMCIDKid oznacza aktualny element struktury, a tylko elementy z tym oznaczeniem serializują /K jako bezpośredni MCID ze stroną niesioną na /Pg; wszystko inne trzyma istniejącą ścieżkę słownika MCR nietkniętą. To świadomy kompromis i taki, który warto nazwać głośno: dodanie profilu nie powinno zmieniać bajtów wyjścia, które każdy inny tagowany dokument już produkuje, więc profil dostaje własną ścieżkę zamiast globalnego przełącznika. Każdy istniejący tagowany plik zachowuje swoją poprzednią serializację, a powierzchnia regresji zostaje wielkości nowej funkcji

Jedno przejście, dwóch konsumentów

PDFlibPas wyprowadza manifest zamiany i werdykt zgodności z tego samego dwuprzebiegowego przejścia, dlatego manifestowi można ufać jako planowi zadania. Przechodzenie wypełnia rekord TPDFVCRManifestData podczas walidacji: Fields z korzenia szablonu, PageSelectionField, gdy jedno jest zadeklarowane, oraz tablicę Placeholders wpisów TPDFVCRPlaceholderInfo niosących FieldName, PageNumber, MCID, HasBBox i BBox. Punktem wejścia zgodności jest CheckCompliancePDFVCR(InputStream, Password, Options, ResultList), który odpala swoje sprawdzenia w kolejności zależności, a nie wszystkie naraz: najpierw pełne sprawdzenie PDF/X, potem GTS_PDFXVersion względem PDF/X-4, potem GTS_PDFVCRVersion czytane z przestrzeni nazw http://www.npes.org/pdfvcr/ns/id/, a dopiero na końcu przejście po strukturze. Przekazanie Options jako 1 zatrzymuje go na pierwszym problemie, czego chcesz w bramie wsadowej; przekazanie 0 zbiera wszystko, czego chcesz w raporcie

Jedno przejście, dwóch konsumentów w PDFlibPas: to samo ograniczone przejście wypełnia TPDFVCRManifestData polami Fields, PageSelectionField i tablicą Placeholders wpisów TPDFVCRPlaceholderInfo, podczas gdy CheckCompliancePDFVCR odpala pełne sprawdzenie PDF/X, GTS_PDFXVersion, GTS_PDFVCRVersion z przestrzeni nazw npes.org i dopiero potem przejście po strukturze, zwracając 100 tylko wtedy, gdy plik się kwalifikuje
Manifest to kontrakt, więc BuildPDFVCRManifest zwraca JSON tylko wtedy, gdy lista problemów wraca pusta, a w przeciwnym razie raportuje błąd 608, nie pozwalając plikowi niezgodnemu udawać godnego zaufania planu zadania
// Zweryfikuj szablon, który otrzymałeś, a nie wyprodukowałeś
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;

Manifest to kontrakt, więc istnieje tylko dla plików zgodnych

BuildPDFVCRManifest zwraca JSON tylko wtedy, gdy lista problemów wraca pusta, a w przeciwnym razie zwraca pusty string. To nie obronne kodowanie samo dla siebie. Manifest jest konsumowany przez silnik kompozycji, który będzie z niego napędzał prawdziwe wyjście, a oddanie częściowej listy pól wydobytej z pliku z dwoma korzeniami szablonu zaprasza silnik do traktowania niezgodnej struktury jako godnego zaufania planu zamiany. W API dokumentowym GetPDFVCRManifest(InputFileName, Password) opakowuje to samo wywołanie i ustawia ostatni kod błędu na PDFLIB_ERROR_PDFVCR_COMPLIANCE (608), gdy plik się nie kwalifikuje, więc nieudana ekstrakcja i pusty szablon są rozróżnialne. JSON raportuje fields, pageSelectionField jako string albo null oraz tablicę placeholders, której wpisy dają field, page, mcid i albo czteroliczbowy bbox, albo null

var
  Lib: TPDFlib;
  Manifest: WideString;
begin
  Lib := TPDFlib.Create;
  try
    Manifest := Lib.GetPDFVCRManifest('campaign-template.pdf', '');
    if Manifest = '' then
      // 608 znaczy, że plik nie jest zgodny z PDF/VCR-1,
      // 401 znaczy, że plik nie mógł zostać otwarty
      Writeln('No manifest, error ', Lib.LastErrorCode)
    else
      Writeln(Manifest);
  finally
    Lib.Free;
  end;
end;

Jeden szczegół do przeniesienia do twojego konsumenta: wartość page trzyma się bibliotecznej konwencji stron liczonej od zera, nie numeracji od jedynki, którą człowiek widzi w przeglądarce. Pomylenie tych dwóch to cichy off-by-one lądujący z treścią na złym arkuszu, a żaden walidator go nie złapie, bo obie liczby są strukturalnie poprawne

Gdzie są granice

Wsparcie PDF/VCR-1 w PDFlibPas pokrywa autorstwo szablonów, bramę zapisu, samodzielny preflight i ekstrakcję manifestu. Nie wykonuje zamiany. Podstawianie danych do placeholderów i produkcja spersonalizowanych instancji to krok kompozycji, i celowo żyje poza biblioteką, bo manifest to właśnie interfejs, który pozwala napisać silnik kompozycji niezależnie. Brama zapisu też odmawia zamiast naprawiać. Inaczej niż w profilach, gdzie złą flagę adnotacji można po cichu znormalizować, każde ograniczenie PDF/VCR-1 egzekwowane przez PDFVCRReadyForSave jest takie, że zgadywanie zmieniłoby znaczenie dokumentu: wymyślenie korzenia szablonu albo wybranie, do którego pola należy niepowiązany placeholder, produkuje plik, który waliduje się i kłamie. Ta sama linia naprawa-kontra-odmowa jest omówiona dla innego profilu w trybie autora PDF/E-1 i ograniczonym preflight, a workflow uruchamiania kilku kontroli zgodności nad jednym dokumentem jest pokryte w workbench zgodności i podpisywania

Autorstwo, ograniczone przejście po strukturze, brama zapisu i ekstraktor manifestu — wszystko to jest w bibliotece PDFlibPas Delphi PDF, co znaczy, że szablon może zostać wyprodukowany pod profilem, a potem zweryfikowany przez osobną ścieżkę kodu, która nigdy nie widziała stanu writera, i ta separacja to jedyny powód, dla którego roszczenie o zgodność jest w ogóle coś warte