Artykuł techniczny

Tworzenie warstw PDF (OCG i OCMD) w Delphi z PDFium

PDFium Component tworzy nowe warstwy PDF z Delphi przez TPdf.SaveAsOptionalContentCreated. W jednym zweryfikowanym zapisie metoda dodaje grupy zawartości opcjonalnej (OCG), słowniki przynależności zawartości opcjonalnej (OCMD) i dostarczony przez wołającego rysunek owinięty w Form XObjects, których wpis /OC wskazuje nową grupę, więc PDFium i inne przeglądarki pokazują albo chowają ten rysunek, gdy warstwa jest przełączana

Żądanie rzadko jest abstrakcyjne. Zespół inżynierii zakładu chce wymiarów metrycznych i imperialnych na tym samym arkuszu rysunku, z widocznym dokładnie jednym zestawem. Drukarnia chce nakładki linii cięcia, która widać na ekranie, ale można ją wyłączyć przed odbitką próbną. Żaden z plików nie ma jeszcze żadnych warstw, więc nie ma czego przełączać. Przełączanie istniejących warstw to inna robota, opisana w przełączaniu istniejących warstw zawartości opcjonalnej PDF przez SaveAsOptionalContentConfigured; ten artykuł jest o tworzeniu warstw od zera

Dlaczego nie wystarczy ustawić /OC na obiekcie strony przez PDFium?

PDFium nie ma publicznej funkcji do przyczepiania zawartości opcjonalnej do istniejącego obiektu strony. Publiczne nagłówki nie zawierają FPDFPageObj_SetOC ani niczego równoważnego, więc zwykła droga edycji-i-FPDFPage_GenerateContent nie wyprodukuje treści bramkowanej. Bramka musi zostać zapisana w strukturze pliku, a jedynym pytaniem jest, którą strukturę PDFium uszanuje przy renderowaniu

ISO 32000-1 §8.11.3 daje trzy sposoby uczynienia grafiki opcjonalną, a PDFium traktuje je różnie:

  • Sekcje strumienia treści strony owinięte w marked content /OC /Name BDC ... EMC, sprawdzane per obiekt strony podczas renderowania
  • Form XObjects i image XObjects niosące wpis /OC we własnym słowniku, sprawdzane, gdy XObject jest malowany
  • Adnotacje z wpisem /OC w słowniku adnotacji, których ścieżka renderowania PDFium w ogóle nie konsultuje, więc bramkowana adnotacja zostaje widoczna niezależnie od stanu warstwy

Owijanie w marked content znaczy przepisywanie istniejących strumieni treści operator po operatorze, a adnotacje wyklucza renderer. Zostaje droga Form XObject i to nią idzie PDFium Component: twój rysunek wchodzi do nowego Form XObject z /OC wskazującym grupę, forma jest rejestrowana w słowniku /Resources /XObject strony, a nowy strumień treści trzymający pojedyncze /Name Do jest doklejany do /Contents strony. Istniejąca treść strony nigdy nie jest parsowana ani przepisywana

Drogi bramkowania zawartości opcjonalnej ISO 32000-1 pod PDFium: marked content między OC BDC a EMC jest sprawdzany per obiekt strony, Form XObject z wpisem /OC jest sprawdzany przy malowaniu i to jest droga, którą PDFium Component pisze z Delphi, podczas gdy /OC adnotacji nigdy nie jest konsultowany
PDFium sprawdza zawartość opcjonalną dla sekcji marked content i słowników XObject, ale nie dla adnotacji, dlatego SaveAsOptionalContentCreated bramkuje dostarczony rysunek przez Form XObject i operator Do

Opisywanie grup i bramkowanych form w Delphi

TPdf.SaveAsOptionalContentCreated przyjmuje trzy tablice i zwraca raport. Oba przeciążenia, jedno piszące do TStream, a jedno do nazwy pliku, dzielą tę samą listę parametrów: Groups: TPdfOptionalContentCreateGroups, Memberships: TPdfOptionalContentCreateMemberships, Forms: TPdfOptionalContentCreateForms, out Report: TPdfOptionalContentCreateReport i opcjonalne AllowSignedDocument: Boolean = False. Metoda dotarła w PDFium Component v3.154.0

Każdy TPdfOptionalContentCreateGroup ma Name, opcjonalne Creator, Intent z pociView albo pociDesign, flagę InitialStateVisible, flagę Locked i RadioTag. Grupy dzielące ten sam RadioTag lądują w jednej tablicy /RBGroups, po której przeglądarka wie, że włączenie jednej wyłącza pozostałe. Każdy TPdfOptionalContentCreateForm nazywa swoją grupę bramkującą przez GroupIndex (indeks liczony od zera w twojej tablicy Groups), PageNumber liczony od 1, surowe bajty strumienia treści, pudełko obejmujące, opcjonalną macierz i opcjonalne ResourceName

var
  Pdf: TPdf;
  Groups: TPdfOptionalContentCreateGroups;
  Forms: TPdfOptionalContentCreateForms;
  Report: TPdfOptionalContentCreateReport;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.FileName := 'sheet-a1.pdf';
    Pdf.Active := True;

    SetLength(Groups, 2);            // SetLength zeruje rekordy
    Groups[0].Name := 'Dimensions (mm)';
    Groups[0].Intent := pociView;
    Groups[0].InitialStateVisible := True;
    Groups[0].RadioTag := 'Units';
    Groups[1].Name := 'Dimensions (in)';
    Groups[1].Intent := pociView;
    Groups[1].InitialStateVisible := False;
    Groups[1].RadioTag := 'Units';

    SetLength(Forms, 2);
    Forms[0].GroupIndex := 0;
    Forms[0].PageNumber := 1;
    Forms[0].Content := TEncoding.ASCII.GetBytes(
      '0 0 1 RG 1.5 w 72 120 m 540 120 l S');
    Forms[0].BBoxRight := 612;
    Forms[0].BBoxTop := 792;
    Forms[0].ResourceName := 'DimMetric';
    Forms[1] := Forms[0];
    Forms[1].GroupIndex := 1;
    Forms[1].Content := TEncoding.ASCII.GetBytes(
      '1 0 0 RG 1.5 w 72 140 m 540 140 l S');
    Forms[1].ResourceName := 'DimImperial';

    if not Pdf.SaveAsOptionalContentCreated('sheet-a1-layers.pdf',
      Groups, nil, Forms, Report) then
      raise Exception.Create(Report.ErrorMessage);
  finally
    Pdf.Free;
  end;
end;

Zauważ, że treść formy to czysty rysunek wektorowy. Komponent pisze wyłącznie pusty słownik /Resources na każdej formie, więc strumień treści musi być samowystarczalny: kolory urządzenia, ścieżki, przycinanie i transformacje działają, ale cokolwiek potrzebującego nazwanego zasobu (font dla Tf, obraz dla Do, ExtGState dla gs) nie ma przy czym rozwiązać. Jeśli zostawisz ResourceName puste, formy są nazywane FmOc1, FmOc2 i tak dalej; własna nazwa może zawierać tylko litery, cyfry, podkreślenie, minus i kropkę

Co OCMD dokłada ponad zwykłą grupę?

Słownik przynależności zawartości opcjonalnej czyni widoczność zależną od kilku grup naraz. ISO 32000-1 §8.11.2.2 definiuje dwie formy: listę /OCs z polityką /P, jak /AnyOn albo /AllOn, i wyrażenie widoczności /VE zbudowane w specyfikacji z /And, /Or i /Not. PDFium Component przyjmuje TPdfOptionalContentCreateMembership z MemberGroupIndices, Policy i Expression; gdy Expression.GroupIndices nie jest puste, wyrażenie wygrywa i /VE jest pisane obok /OCs

procedure AddUnitMemberships(var Memberships: TPdfOptionalContentCreateMemberships);
begin
  SetLength(Memberships, 2);

  // Widoczne, gdy dowolna warstwa jednostek jest włączona: /OCs [...] /P /AnyOn
  SetLength(Memberships[0].MemberGroupIndices, 2);
  Memberships[0].MemberGroupIndices[0] := 0;
  Memberships[0].MemberGroupIndices[1] := 1;
  Memberships[0].Policy := ocmpAnyOn;

  // Widoczne tylko, dopóki warstwa imperialna jest wyłączona: /VE [ /Not ref ]
  SetLength(Memberships[1].MemberGroupIndices, 1);
  Memberships[1].MemberGroupIndices[0] := 1;
  Memberships[1].Expression.Kind := poceNot;
  SetLength(Memberships[1].Expression.GroupIndices, 1);
  Memberships[1].Expression.GroupIndices[0] := 1;
end;

Wspierany podzbiór jest celowo wąski. Polityki są ograniczone do ocmpAllOn i ocmpAnyOn; ocmpAnyOff i ocmpAllOff są odrzucane. Wyrażenia są jeden poziom głębokie, z poceAll, poceAny albo poceNot nad utworzonymi grupami, a /Not wymaga dokładnie jednego indeksu grupy. poceAll serializuje się jako /And, poceAny jako /Or a poceNot jako /Not, jedyne trzy operatory, które §8.11.2.2 definiuje. Wersje przed 3.181.2 pisały /All i /Any, które PDFium ocenia jako ukryte, więc jeśli tworzyłeś OCMD starszą budową, odtwórz je, zamiast ufać wyrażeniu. Każde przynależenie potrzebuje też co najmniej jednego wpisu w MemberGroupIndices, nawet gdy wyrażenie jest obecne, bo ta lista staje się tablicą /OCs. Numery obiektów nowych słowników wracają w Report.CreatedMembershipObjectNumbers

Słowniki przynależności zawartości opcjonalnej tworzone przez PDFium Component w Delphi: lista /OCs z polityką /AnyOn albo /AllOn ocenia stany grup razem, podczas gdy wyrażenie widoczności /VE, jak /Not na grupie, bramkuje treść logiką And, Or i Not jeden poziom głęboko
Polityki oceniają listę /OCs, a wyrażenia /VE łączą grupy przez /And, /Or i /Not - tak czy inaczej przynależenie potrzebuje co najmniej jednej grupy członkowskiej do referowania

Dwa zachowania warto znać, zanim zbudujesz na OCMD. Po pierwsze, bramkowane formy w tym samym wywołaniu referują wyłącznie grupy: TPdfOptionalContentCreateForm.GroupIndex indeksuje Groups, nie Memberships, więc forma nie da się zbramkować świeżo utworzonym OCMD przez to API. Po drugie, PDFium Component dokłada każdy nowy OCMD do tablicy /OCProperties /OCGs katalogu, podczas gdy /ON, /OFF i /Order zostają tylko dla grup. ISO 32000-1 §8.11.4.2 opisuje /OCGs jako listę grup, ale bez tej rejestracji TPdf.InspectOptionalContent w ogóle nie wylicza przynależenia. Skutkiem ubocznym jest to, że inwentarz wyjścia wypisuje każdy OCMD jako dodatkowy nienazwany wpis w Inventory.Groups, więc dopasowuj grupy po Name, a nie po pozycji

Jak komponent łata istniejące /OCProperties?

TPdf.SaveAsOptionalContentCreated buduje /OCProperties od zera albo rozszerza to, które już jest w katalogu, i rozszerza przez doklejanie, nigdy przez przebudowę. Dokument bez warstw dostaje nowy słownik z /OCGs i domyślną konfiguracją /D niosącą /ON, /OFF i /Order, plus /Locked i /RBGroups, gdy któraś grupa o nie prosi, jak rozkłada to ISO 32000-1 §8.11.4.3

Dla dokumentu, który już ma warstwy, każda docelowa tablica jest łatana przez wstawienie nowych referencji tuż przed jej zamykającym nawiasem. Brzmi to pedanteryjnie i jest z powodu. Tablica /Order może trzymać zagnieżdżone tablice i etykiety tekstowe, które kształtują drzewo warstw pokazywane przez przeglądarkę, a wcześniejsze podejście, które spłaszczało i przebudowywało tablicę, gubiło te etykiety. Wstawienie na końcu trzyma istniejące drzewo nietknięte i po prostu wypisuje nowe grupy po nim. Tablice mieszkające we własnych obiektach pośrednich są rozwiązywane i łatane na miejscu

Rozszerzanie istniejącego słownika /OCProperties przez PDFium Component w Delphi: nowe referencje grup zawartości opcjonalnej są wstawiane tuż przed zamykającym nawiasem, więc zagnieżdżone tablice /Order i etykiety tekstowe przeżywają, a pośrednia domyślna konfiguracja /D jest śledzona i łatana na miejscu
Doklejanie referencji przed zamykającym nawiasem trzyma istniejące drzewo /Order nietknięte, czego przebudowa spłaszczająca zagnieżdżone tablice i etykiety nie zagwarantuje

Od v3.175.0 sama domyślna konfiguracja też może być pośrednia. Niektórzy producenci piszą /OCProperties << /OCGs [...] /D 12 0 R >>, a PDFium Component podąża teraz za tą referencją i łata słownik docelowy, zamiast zawodzić błędem zniekształconego wpisu. Bezpośrednie i pośrednie słowniki /Resources stron, łącznie z pośrednim podsłownikiem /XObject, są traktowane tak samo

Dowodzenie, że nowe warstwy faktycznie się przełączają

Warstwa, która istnieje w /OCProperties, ale niczego nie bramkuje, jest łatwa do wyprodukowania i trudna do zauważenia. Podczas rozwoju pierwsza budowa writera form owijała obiekt strumienia, który już niósł własny nagłówek N 0 obj i trailer endobj, w drugi nagłówek. PDFium nie umiał sparsować zdwojonego obiektu i zamiast zgłosić błąd po cichu pomijał operator Do. Plik się otwierał, panel warstw wyglądał dobrze, a bramkowany rysunek nigdy się nie pojawiał. Obnażyła go sonda, która przełączała warstwę i porównywała wyrenderowane piksele: bez różnicy, więc coś było nie tak

Dlatego metoda weryfikuje przed wydaniem. Wyjście jest otwierane ponownie przez ścisłe wczytanie sprawdzające tabelę odwołań krzyżowych, a każda żądana grupa jest czytana z powrotem przez InspectOptionalContent, by potwierdzić jej nazwę, stan początkowy i rejestrację zablokowania. Dopiero potem wyjście jest kopiowane do twojego strumienia, a wariant plikowy pisze plik tymczasowy i wymienia cel atomowo. Ten sam test przełączania możesz puścić sam, otwierając wyjście ponownie, znajdując grupę po nazwie i zapisując kopię z przeciwnym stanem

function SaveImperialPreset(const SourceName, TargetName: string): Boolean;
var
  Pdf: TPdf;
  Inventory: TPdfOptionalContentInventory;
  Changes: TPdfOptionalContentStateChanges;
  Options: TPdfOptionalContentConfigureOptions;
  CfgReport: TPdfOptionalContentConfigureReport;
  I: Integer;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.FileName := SourceName;
    Pdf.Active := True;
    if not Pdf.InspectOptionalContent(Inventory) then
      raise Exception.Create(Inventory.ErrorMessage);

    for I := 0 to High(Inventory.Groups) do
      if (Inventory.Groups[I].Name = 'Dimensions (mm)') or
         (Inventory.Groups[I].Name = 'Dimensions (in)') then
      begin
        SetLength(Changes, Length(Changes) + 1);
        Changes[High(Changes)].GroupObjectNumber := Inventory.Groups[I].ObjectNumber;
        Changes[High(Changes)].Visible := Inventory.Groups[I].Name = 'Dimensions (in)';
      end;

    Options := TPdfOptionalContentConfigureOptions.Default;
    Result := Pdf.SaveAsOptionalContentConfigured(TargetName, Changes,
      Options, CfgReport);
  finally
    Pdf.Free;
  end;
end;

Zrenderuj stronę pierwszą obu plików, a niebieska i czerwona linia powinny się zamienić. Jeśli się nie zamieniają, obejrzyj najpierw wartość EffectiveVisible grupy w inwentarzu; odzwierciedla ona stan, który PDFium faktycznie zrenderuje po nałożeniu tablic konfiguracji i reguł użycia

Limity i pułapki do obwieścia planem

TPdf.SaveAsOptionalContentCreated to ograniczony profil, a większość zaskoczeń bierze się z traktowania go jak ogólnego edytora warstw. Ograniczenia niżej są egzekwowane z góry, więc naruszające wywołanie zawodzi z ustawionym Report.ErrorMessage i nie pisze żadnego wyjścia:

  • Potrzebna jest co najmniej jedna grupa; grupy, przynależenia i formy mają limit 4096 wpisów każde, nazwy grup muszą być unikalne i mieć od 1 do 255 znaków, a każda forma przyjmuje najwyżej 16 MiB bajtów treści
  • Zaszyfrowane dokumenty są odrzucane, a podpisany dokument potrzebuje AllowSignedDocument = True; wyjście to pełne przepisanie, więc istniejące podpisy przestaną się walidować
  • Każdy wpis Forms staje się własnym Form XObject, więc włożenie tej samej nakładki na 200 stron przechowuje treść 200 razy, a nie raz
  • Nowy strumień Do jest doklewany po istniejącej treści strony, więc biegnie w takim stanie graficznym, w jakim zostawiły go wcześniejsze strumienie; dobrze uformowane strony przywracają swój stan, ale strona z niezbalansowanym q/Q może przesunąć twoją nakładkę
  • Istniejąca treść strony nigdy nie jest przenoszona do warstwy; bramkowany jest tylko rysunek, który podasz w Forms

Punkt powtarzania to ten, który trzeba zważyć dla dużych dokumentów. Jeśli nakładka jest ciężka i powtarza się na każdej stronie, podejście współdzielonych obiektów z wielorazowych stempli stron zbudowanych na Form XObjects trzyma jedną kopię w pliku, kosztem obsłużenia bramki warstwy samemu

FAQ

Czy PDFium Component włoży istniejącą treść strony do nowej warstwy?

Nie. TPdf.SaveAsOptionalContentCreated bramkuje wyłącznie treść, którą podasz w Forms; nie owija istniejących operatorów w marked content ani nie przenosi ich do XObject. Żeby warstwować istniejącą grafikę, odtwórz ją jako samowystarczalną treść formy i usuń pierwotny rysunek osobnym krokiem

Dlaczego moje bramkowane adnotacje są wciąż widoczne, gdy warstwa jest wyłączona?

PDFium nie sprawdza wpisu /OC słowników adnotacji na swojej ścieżce renderowania, więc bramkowanie na poziomie adnotacji nie ma efektu w przeglądarkach opartych na PDFium. Dlatego PDFium Component przyczepia treść warstw przez Form XObjects, które PDFium faktycznie bramkuje

Jakich numerów obiektów używać przy późniejszym przełączaniu nowych warstw?

Report.CreatedGroupObjectNumbers trzyma numery w pliku, który właśnie został zapisany. Gdy otwierasz ten plik ponownie i konfigurujesz go, bierz ObjectNumber z TPdf.InspectOptionalContent i dopasowuj po Name, co pozostaje poprawne nawet, jeśli późniejszy zapis przenumeruje obiekty

PDFium Component wysyła SaveAsOptionalContentCreated, SaveAsOptionalContentConfigured i InspectOptionalContent jako jeden zestaw narzędzi zawartości opcjonalnej dla Delphi i C++Builder, zbudowany na renderze PDFium. Szczegóły i pobrania są na stronie produktu PDFium Component