PDFium Component otagowuje istniejącą treść PDF z Delphi przez EditPdfDocumentStructure w FPdfDocumentStructure.pas. Operacje psekAddMarkedContent i psekAddObjectReference robią więcej, niż dokleić MCR albo OBJR do /K elementu struktury: alokują też klucz /StructParents albo /StructParent i zapisują pasujący wpis /ParentTree, czyli odwrotny indeks, którego wymaga ISO 32000-1 §14.7.4.4
Druga połowa to miejsce, gdzie doszywanie tagów zwykle się psuje. Dodanie owijki BDC i MCR jest łatwe, a przeglądarka chodząca po drzewie struktury od góry z radością znajdzie nową treść. Technologia wspomagająca idzie jednak często w drugą stronę: startuje od kawałka treści na stronie i pyta, który element struktury go posiada. Jeśli strona nie ma klucza /StructParents, albo /ParentTree nie ma wpisu dla nowego MCID, to wyszukiwanie zawodzi, a checkery zgłaszają brakujące albo niespójne drzewo rodziców. Plik wygląda na otagowany i dalej nie działa
Co /ParentTree faktycznie mapuje?
/ParentTree to drzewo liczb mapujące klucze całkowite na elementy struktury, a klucze pochodzą z treści, nie z drzewa. ISO 32000-1 §14.7.4.4 daje każdej stronie albo Form XObject zawierającemu marked content liczbę /StructParents, a wartość drzewa rodziców pod tym kluczem to tablica indeksowana MCID: wpis 3 tablicy to element struktury posiadający <</MCID 3>>. Adnotacja albo XObject użyty jako całkowity element treści dostaje zamiast tego pojedynczy klucz /StructParent, a jego wartością drzewa rodziców jest sam element struktury, nie tablica
Format pojemnika to ogólne drzewo liczb z §7.9.7. Liść trzyma /Nums, płaską tablicę par klucz-wartość posortowaną rosnąco po kluczu. Węzły pośrednie i liście pod korzeniem niosą /Limits z najmniejszym i największym kluczem, jaki pokrywają, podczas gdy korzeń sam nie może mieć /Limits. Do tego korzeń drzewa struktury może nieść /ParentTreeNextKey (§14.7.2), następny klucz, jaki writer powinien rozdać. Każda edycja tagująca nową treść dotyka więc do czterech obiektów, które muszą się zgadzać: strumienia treści, słownika strony albo formy, liścia drzewa rodziców i korzenia drzewa struktury
Kierunek w przód ma własne reguły, rozpisane w §14.7.4.2 i §14.7.4.3. Referencja marked content (MCR) dla treści we własnym strumieniu treści strony potrzebuje tylko /Pg i /MCID. Gdy marked content mieszka wewnątrz Form XObject, MCR potrzebuje też /Stm nazywającego ten strumień, bo MCID są numerowane per strumień treści. Referencja obiektu (OBJR) nazywa adnotację albo XObject przez /Obj i jego stronę przez /Pg
Tagowanie strumienia treści przez EditPdfDocumentStructure
EditPdfDocumentStructure to wsadowy edytor strzeżony migawką. Najpierw wołasz ReadPdfDocumentStructure, żeby dostać TPdfDocumentStructureSnapshot, a potem odsyłasz jego SourceSha256 razem z tablicą rekordów TPdfStructureEditOperation. Jeśli bajty źródła zmieniły się w międzyczasie, edycja jest odrzucana bez wyjścia. Istniejące elementy są adresowane ich numerem obiektu w NodeID, a 0 oznacza korzeń drzewa struktury
uses
System.SysUtils, System.Classes, System.IOUtils, FPdfDocumentStructure;
function TagContentStream(const FileName, OutName: string;
ElementID, StreamObj, StreamGen: Integer): Boolean;
var
Source: TMemoryStream;
Options: TPdfStructureEditOptions;
Snapshot: TPdfDocumentStructureSnapshot;
Ops: TPdfStructureEditOperations;
Report: TPdfStructureEditReport;
Output: TBytes;
begin
Source := TMemoryStream.Create;
try
Source.LoadFromFile(FileName);
Options := TPdfStructureEditOptions.Default;
if not ReadPdfDocumentStructure(Source, Options, Snapshot, Report) then
raise Exception.Create(Report.ErrorMessage);
SetLength(Ops, 1);
Ops[0] := Default(TPdfStructureEditOperation);
Ops[0].Kind := psekAddMarkedContent;
Ops[0].NodeID := ElementID; // istniejący element, po numerze obiektu
Ops[0].TargetObjectNumber := StreamObj; // strumień treści strony albo Form XObject strony
Ops[0].TargetGeneration := StreamGen;
Result := EditPdfDocumentStructure(Source, Snapshot.SourceSha256,
Ops, Options, Output, Report);
if not Result then
raise Exception.Create(Report.ErrorMessage);
TFile.WriteAllBytes(OutName, Output);
finally
Source.Free;
end;
end;
Dla tej jednej operacji EditPdfDocumentStructure owija strumień docelowy w /P <</MCID n>> BDC ... EMC, dokleja MCR do /K elementu, daje stronie (albo formie) klucz /StructParents, jeśli go nie ma, i zapisuje element n do tablicy drzewa rodziców dla tego klucza. Wynik jest zapisywany jako aktualizacja przyrostowa po pierwotnych bajtach, a Report.CreatedMarkedContents mówi ci, ile MCR powstało. Gdy strumień zawiera już zbalansowane pary BDC/EMC, owijany jest tylko nieotagowany ogon po ostatnim najwyższym poziomie EMC; inaczej cały strumień
Jak wybiera się nowe MCID i klucze drzewa rodziców?
Nowe MCID to bieżące maksimum plus jeden, a nowy klucz drzewa rodziców to Max(MaxKey + 1, ParentTreeNextKey). Dla MCID EditPdfDocumentStructure bierze maksimum z MCR już zapisanych dla tej strony (albo tego Form XObject) i każdego /MCID, jaki znajdzie we własnych listach własności strumienia. Liczenie obu ma znaczenie: drzewo struktury mogło zgubić MCR, podczas gdy treść wciąż niesie znacznik, i reużycie tej liczby sprawiłoby, że dwa elementy roszczą sobie tę samą treść
Logika klucza idzie za tą samą defensywną myślą. Jeśli posiadacz ma już wartość /StructParents, istniejąca tablica jest rozszerzana pod indeksem MCID, z null wypełniającym każdą dziurę, i żaden nowy klucz nie jest alokowany. Jeśli nie, komponent skanuje każdy liść /Nums za najwyższym kluczem, porównuje go z /ParentTreeNextKey i bierze większe. Nieświeże /ParentTreeNextKey niższe od kluczy już w użyciu i takie, które rezerwuje klucze ponad ostatnim wpisem, są oba obsłużone, bo większa z dwóch wartości jest zawsze wolna. Po alokacji /ParentTreeNextKey jest przesuwane na nowy klucz plus jeden
Nowa para jest wstawiana do /Nums w kolejności rosnącej, a nie doklejana, bo wyszukiwania w drzewie liczb polegają na tej kolejności. Jeśli liść niesie /Limits, granice są przeliczane z przepisanej tablicy. Gdy korzeń drzewa struktury nie ma w ogóle /ParentTree, co jest częste w plikach tagowanych przez narzędzia piszące tylko drzewo w przód, EditPdfDocumentStructure syntetyzuje nowy pośredni obiekt << /Nums [] >> i podpina go z /StructTreeRoot przed wstawieniem pierwszego wpisu
Form XObjects, /StmOwn i referencje obiektów
Marked content wewnątrz Form XObject jest wspierany tylko, gdy ta forma jest używana przez dokładnie jedną stronę. MCR, który EditPdfDocumentStructure pisze dla formy, niesie /Pg, /Stm nazywające strumień formy i /StmOwn nazywające stronę, a klucz /StructParents idzie na słownik formy, a nie strony. Forma rysowana przez trzy strony ma trzech wiarygodnych właścicieli, więc jakiekolwiek pojedyncze /StmOwn byłoby złe dla dwóch z nich. Komponent odmawia edycji z błędem, zamiast zgadywać
Ta sama reguła unikalności dotyczy psekAddObjectReference. Cel musi być adnotacją występującą w /Annots dokładnie jednej strony, albo Form albo Image XObject występującym w zasobach dokładnie jednej strony; wszystko inne jest odrzucane. OBJR dostaje /Obj i /Pg, cel dostaje klucz /StructParent (reużywany, jeśli jest), a wartością drzewa rodziców jest goła referencja do elementu. To zwykła naprawa adnotacji odnośnika albo obrazu figury, który był otagowany wizualnie, ale nigdy nie został podpięty do drzewa
// Owiń nieotagowany obraz w nowy element Figure, potem zwiąż go dwoma sposobami:
// jako OBJR dla XObject obrazu i jako marked content na stronie
SetLength(Ops, 3);
Ops[0] := Default(TPdfStructureEditOperation);
Ops[0].Kind := psekInsertContainer;
Ops[0].NodeID := -1; // tymczasowy identyfikator, musi być ujemny i unikalny
Ops[0].ParentNodeID := SectID; // istniejący element Sect
Ops[0].Value := 'Figure'; // standardowa rola albo wpis RoleMap
Ops[1] := Default(TPdfStructureEditOperation);
Ops[1].Kind := psekAddObjectReference;
Ops[1].NodeID := -1; // referuje kontener utworzony wyżej
Ops[1].TargetObjectNumber := ImageObj;
Ops[1].TargetGeneration := 0;
Ops[2] := Default(TPdfStructureEditOperation);
Ops[2].Kind := psekAddMarkedContent;
Ops[2].NodeID := -1;
Ops[2].TargetObjectNumber := ContentObj;
Ops[2].TargetGeneration := 0;
if EditPdfDocumentStructure(Source, Snapshot.SourceSha256, Ops, Options,
Output, Report) then
Writeln(Format('Figure is object %d, %d OBJR, %d MCR', [
Report.InsertedNodes[0].ObjectNumber,
Report.CreatedObjectReferences, Report.CreatedMarkedContents]));
Ujemne wartości NodeID nazywają kontenery utworzone wcześniej w tej samej partii, a Report.InsertedNodes mapuje każde żądane ID na numer obiektu, jaki dostało. psekInsertContainer przyjmuje standardowe role PDF 1.7, PDF 2.0 i PDF/UA, jak P, H1 do H6, Figure, Table albo LI, plus role własne już zmapowane w /RoleMap. Ustawienie BeforeNodeID na dwóch operacjach treściowych kładzie nowy MCR albo OBJR przed istniejącym dzieckiem, zamiast na końcu
Dlaczego edycja tego samego strumienia dwa razy w jednej partii wymaga ostrożności?
Partia może trafiać w ten sam obiekt wielokrotnie, a każda późniejsza operacja musi widzieć wynik wcześniejszych. Dwie operacje psekAddMarkedContent na tej samej stronie obie przepisują jej strumień treści i obie aktualizują ten sam liść drzewa rodziców. Implementacja, która przygotowuje każdą edycję jako osobny doklejany obiekt, zapisuje dwie wersje obiektu 12 w aktualizacji. Zależnie od tego, którą wersję czytnik zatrzyma, tracisz pierwszą owijkę BDC albo pierwszą parę /Nums, a drugi MCID może kolidować z pierwszym, bo był policzony z nieświeżych bajtów
EditPdfDocumentStructure kluczuje przygotowane aktualizacje przez (ObjectNumber, Generation) i nadpisuje istniejący wpis na miejscu, a przy tym odświeża kopię obiektu w pamięci. Druga operacja parsuje więc już owinięty strumień, widzi MCID 0, alokuje MCID 1 i rozszerza tę samą tablicę drzewa rodziców. Jeden obiekt, jedna wersja końcowa. Zanim cokolwiek zostanie zwrócone, wyjście jest czytane z powrotem i porównywane z modelem w pamięci: liczba elementów, liczniki elementów treści i każdy zachowany MCR albo OBJR muszą pasować, albo wywołanie zawodzi bez wyjścia
Strumienie skompresowane idą tą samą drogą. Strumień treści z /Filter /FlateDecode (albo jednoelementową tablicą [/FlateDecode]) i bez predyktora /DecodeParms jest rozprężany ograniczonym dekoderem z sufitem 32 MiB albo MaxOutputBytes, cokolwiek mniejsze, owijany i rekompresowany z powrotem do /FlateDecode z nowym /Length. Strumień, który rozprężyłby się ponad sufit, jest odrzucany, a nie ucinany, co ma znaczenie dla plików z niezaufanych źródeł
Limity warte znajomości przed przebiegiem naprawy PDF/UA
EditPdfDocumentStructure jest konserwatywny z założenia, a większość jego granic wychodzi jako jasny Report.ErrorMessage, a nie jako po cichu zepsuty plik. Miej je na uwadze, planując partię napraw:
- Owijane mogą być tylko strumienie niefiltrowane i zwykłe
FlateDecode; inne filtry, albo Flate z predyktorem, są odrzucane - Strumień treści musi należeć do
/Contentsdokładnie jednej strony, a Form XObjects, adnotacje i XObjects muszą być używane przez dokładnie jedną stronę - Owijka zawsze używa tagu
/P; rola semantyczna pochodzi z/Selementu struktury, nie z nazwy taguBDC - Synchronizacja drzewa rodziców czyta płaski korzeń
/Numsalbo jeden poziom liści/Kids; głębsze węzły pośrednie nie są schodzone, więc waliduj takie pliki ponownie po edycji - Pliki zawierające podpisy albo pola podpisów są odrzucane kategorycznie, więc podpisuj po naprawie struktury, nie przed
Krok walidacji nie jest opcjonalny dla potoku napraw. Puść walidację drzewa struktury PDF/UA w PDFium Component przed partią i po niej, a użyj API marked content obiektów strony do czytania tagów BDC i MCID, gdy musisz potwierdzić, które obiekty strony skończyły wewnątrz nowych znaczników. Czyste drzewo rodziców mówi ci, że wyszukiwania się rozwiązują; nie mówi ci, że kolejność czytania albo tekst alternatywny są właściwe
FAQ
Czy EditPdfDocumentStructure przebudowuje całe /ParentTree?
Nie. EditPdfDocumentStructure aktualizuje wyłącznie wpisy dla treści, którą taguje w bieżącej partii, reużywając istniejących kluczy /StructParents i alokując nowe tam, gdzie trzeba, więc istniejące wpisy drzewa rodziców zostają nietknięte
Co się dzieje, gdy PDF ma drzewo struktury, ale nie ma /ParentTree?
Komponent tworzy pośrednie drzewo liczb << /Nums [] >>, podpina je z /StructTreeRoot i wstawia pierwszy klucz dla tagowanej właśnie treści
Czy mogę tagować treść w Form XObject współdzielonym przez kilka stron?
Nie tą operacją. Współdzielona forma czyni /StmOwn wieloznacznym, więc psekAddMarkedContent wymaga, by forma była referowana przez dokładnie jedną stronę, a inaczej zgłasza błąd
Czytanie i edycja drzewa struktury, walidacja PDF/UA i dostęp do marked content są częściami PDFium Component for Delphi and C++Builder, które opakowuje PDFium dla projektów Delphi, C++Builder i Lazarus