Technický článek

Udržování /ParentTree a /StructParents v souladu v Delphi

PDFium Component označuje existující obsah PDF z Delphi přes EditPdfDocumentStructure v FPdfDocumentStructure.pas. Operace psekAddMarkedContent a psekAddObjectReference dělají víc, než že připojí MCR nebo OBJR do /K strukturního elementu: taky alokují klíč /StructParents nebo /StructParent a zapisují odpovídající položku /ParentTree, což je inverzní index, který vyžaduje ISO 32000-1 §14.7.4.4

Právě ta druhá polovina je místo, kde dodatečné označování obvykle selhává. Přidat wrapper BDC a MCR je snadné a prohlížeč, který jde stromem struktury shora, nový obsah rád najde. Asistivní technologie jde ale často opačným směrem: vychází z kusu obsahu na stránce a ptá se, který strukturní element ho vlastní. Nemá-li stránka klíč /StructParents, nebo nemá-li /ParentTree položku pro nový MCID, tohle hledání selže a kontroly to hlásí jako chybějící nebo nekonzistentní parent tree. Soubor vypadá označený a pořád nefunguje

Co /ParentTree doopravdy mapuje?

/ParentTree je number tree, který mapuje celočíselné klíče na strukturní elementy, a klíče pocházejí z obsahu, ne ze stromu. ISO 32000-1 §14.7.4.4 dává každé stránce nebo Form XObject obsahující marked content integer /StructParents a hodnota parent tree pod tím klíčem je pole indexované MCID: položka 3 pole je strukturní element, který vlastní <</MCID 3>>. Anotace nebo XObject použitý jako obsahová položka celek dostane místo toho jediný klíč /StructParent a jeho hodnota parent tree je samotný strukturní element, ne pole

Inverzní index parent tree PDF/UA zapsaný PDFium Component v Delphi: označení obsahu nesoucí MCID 3 se řeší přes klíč stránky /StructParents 17 do number tree /ParentTree, jehož položka pole 3 je strukturní element vlastnící označený obsah
Pořadí čtení u tagovaného PDF záleží: asistivní technologie jde od MCID na stránce přes /StructParents do pole /ParentTree a chybějící klíč nebo položka rozbije tohle hledání, i když samotný strom struktury vypadá v pořádku

Kontejnerový formát je obecný number tree z §7.9.7. List drží /Nums, ploché pole párů klíč a hodnota seřazené vzestupně podle klíče. Mezilehlé a listové uzly pod kořenem nesou /Limits s nejmenším a největším klíčem, který pokrývají, zatímco kořen sám nesmí mít /Limits. Navíc kořen stromu struktury může nést /ParentTreeNextKey (§14.7.2), další klíč, který by writer měl vydat. Každá editace označující nový obsah se proto dotkne až čtyř objektů, které se musí shodovat: content streamu, slovníku stránky nebo formu, listu parent tree a kořene stromu struktury

Dopředný směr má vlastní pravidla, stanovená v §14.7.4.2 a §14.7.4.3. Reference marked content (MCR) pro obsah ve vlastním content streamu stránky potřebuje jen /Pg a /MCID. Žije-li marked content uvnitř Form XObject, potřebuje MCR taky /Stm pojmenovávající ten stream, protože MCID se číslují na každý content stream. Reference objektu (OBJR) pojmenovává anotaci nebo XObject přes /Obj a jeho stránku přes /Pg

Označení content streamu přes EditPdfDocumentStructure

EditPdfDocumentStructure je dávkový editor hlídaný snapshotem. Nejdřív zavolejte ReadPdfDocumentStructure, abyste dostali TPdfDocumentStructureSnapshot, a pak předejte jeho SourceSha256 zpět spolu s polem záznamů TPdfStructureEditOperation. Změnily-li se zdrojové bajty mezitím, editace se odmítne bez výstupu. Existující elementy se adresují jejich číslem objektu v NodeID a 0 označuje kořen stromu 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;            // existující element, podle čísla objektu
    Ops[0].TargetObjectNumber := StreamObj; // content stream stránky nebo Form XObject stránky
    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;

U té jedné operace EditPdfDocumentStructure zabalí cílový stream do /P <</MCID n>> BDC ... EMC, připojí MCR do /K elementu, dá stránce (nebo formu) klíč /StructParents, pokud ho nemá, a zapíše element n do pole parent tree pro ten klíč. Výsledek se zapisuje jako inkrementální update za původními bajty a Report.CreatedMarkedContents říká, kolik MCR vzniklo. Obsahuje-li stream už vyvážené páry BDC/EMC, zabalí se jen neoznačený ocas za posledním top-level EMC; jinak celý stream

Jak se vybírají nové MCID a klíče parent tree?

Nové MCID je aktuální maximum plus jedna a nový klíč parent tree je Max(MaxKey + 1, ParentTreeNextKey). U MCID bere EditPdfDocumentStructure maximum napříč MCR už zaznamenanými pro tu stránku (nebo ten Form XObject) a každé /MCID, které najde ve vlastních property lists streamu. Počítat obojí záleží: strom struktury mohl ztratit MCR, zatímco obsah značku pořád nese, a znovupoužití toho čísla by udělalo, že si dva elementy nárokují tentýž obsah

Logika klíče následuje tutéž defenzivní myšlenku. Má-li vlastník už hodnotu /StructParents, stávající pole se rozšíří na indexu MCID, s null vyplňujícím jakoukoli díru, a žádný nový klíč se nealokuje. Nemá-li ho, komponenta skenuje každý list /Nums po nejvyšším klíči, srovná ho s /ParentTreeNextKey a bere větší. Zastaralé /ParentTreeNextKey nižší než už používané klíče i jedno, které si rezervuje klíče za poslední položkou, se obě obslouží, protože větší ze dvou hodnot je vždycky volná. Po alokaci se /ParentTreeNextKey posune na nový klíč plus jedna

Alokace MCID a klíče parent tree ve strukturním editoru PDFium Component pro Delphi: nové MCID je o jednu nad maximem už zaznamenaným v MCR a property lists streamu a klíč je větší z nejvyššího klíče /Nums plus jedna a /ParentTreeNextKey, s null vyplňujícím mezery pole
EditPdfDocumentStructure odvozuje nové MCID z MCR i značek ve streamu a alokuje klíč parent tree defenzivně, takže zastaralé /ParentTreeNextKey nebo znovupoužité MCID nemůže udělat, že si dva elementy nárokují tentýž obsah

Nový pár se do /Nums vkládá vzestupně místo připojování, protože hledání v number tree spoléhají na to pořadí. Nese-li list /Limits, meze se přepočtou z přepsaného pole. Má-li kořen stromu struktury vůbec žádný /ParentTree, což je běžné v souborech označených nástroji, které zapsaly jen dopředný strom, syntetizuje EditPdfDocumentStructure nový indirect objekt << /Nums [] >> a odkáže ho z /StructTreeRoot, než vloží první položku

Form XObjects, /StmOwn a reference objektů

Marked content uvnitř Form XObject je podporované jen tehdy, když ten form používá přesně jedna stránka. MCR, který EditPdfDocumentStructure píše pro form, nese /Pg, /Stm pojmenovávající stream formu a /StmOwn pojmenovávající stránku a klíč /StructParents jde na slovník formu, ne na stránku. Form nakreslený třemi stránkami má tři věrohodné vlastníky, takže jakékoli jediné /StmOwn by bylo pro dva z nich špatné. Komponenta odmítne editaci chybou místo hádání

Pravidlo jednoho vlastníka pro označování obsahu Form XObject PDFium Component v Delphi: MCR na formu používaném jedinou stránkou nese /Pg, /Stm a /StmOwn s /StructParents na slovníku formu, zatímco form nakreslený třemi stránkami dělá /StmOwn nejednoznačným a editace se odmítne
Klíč /StmOwn dává smysl jen tehdy, když form vlastní jedna stránka, proto se sdílené formy a sdílené anotace odmítají místo označení hádatelným vlastníkem

Totéž pravidlo jedinečnosti platí pro psekAddObjectReference. Cílem musí být anotace objevující se v /Annots přesně jedné stránky, nebo Form nebo Image XObject objevující se ve zdrojích přesně jedné stránky; cokoli jiného se odmítá. OBJR dostane /Obj a /Pg, cíl dostane klíč /StructParent (znovu použitý, je-li přítomný) a hodnota parent tree je prostá reference na element. To je obvyklá oprava anotace odkazu nebo obrázku figure, který se označil vizuálně, ale nikdy se nepřipojil ke stromu

// Zabalte neoznačený obrázek do nového elementu Figure, pak ho svažte dvěma způsoby:
// jako OBJR pro image XObject a jako marked content na stránce
SetLength(Ops, 3);
Ops[0] := Default(TPdfStructureEditOperation);
Ops[0].Kind := psekInsertContainer;
Ops[0].NodeID := -1;              // dočasné ID, musí být záporné a unikátní
Ops[0].ParentNodeID := SectID;    // existující element Sect
Ops[0].Value := 'Figure';         // standardní role nebo položka RoleMap

Ops[1] := Default(TPdfStructureEditOperation);
Ops[1].Kind := psekAddObjectReference;
Ops[1].NodeID := -1;              // odkazuje na kontejner vytvořený výše
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]));

Záporné hodnoty NodeID jmenují kontejnery vytvořené dřív ve stejné dávce a Report.InsertedNodes mapuje každé požadované ID na číslo objektu, které dostalo. psekInsertContainer přijímá standardní role PDF 1.7, PDF 2.0 a PDF/UA jako P, H1 až H6, Figure, Table nebo LI, plus vlastní role už namapované v /RoleMap. Nastavení BeforeNodeID u dvou obsahových operací umístí nové MCR nebo OBJR před existující dítě místo na konec

Proč potřebuje editace téhož streamu dvakrát v jedné dávce péči?

Dávka může narazit na tentýž objekt opakovaně a každá pozdější operace musí vidět výsledek dřívějších. Dvě operace psekAddMarkedContent proti téže stránce přepíšou obě její content stream a obě updatují tentýž list parent tree. Implementace, která připraví každou editaci jako samostatný připojený objekt, zapisuje dvě verze objektu 12 v update. Podle toho, kterou si čtečka ponechá, ztratíte první wrapper BDC nebo první pár /Nums a druhé MCID může kolidovat s prvním, protože se počítalo ze zastaralých bajtů

EditPdfDocumentStructure klíčuje připravené updaty přes (ObjectNumber, Generation) a přepisuje existující položku na místě a současně obnovuje kopii objektu v paměti. Druhá operace proto parsuje už zabalený stream, vidí MCID 0, alokuje MCID 1 a rozšiřuje tentýž pole parent tree. Jeden objekt, jedna finální verze. Než se cokoli vrátí, výstup se přečte zpět a srovná s modelem v paměti: počet elementů, počty obsahových položek a každý ponechaný MCR nebo OBJR se musí shodovat, jinak volání selže bez výstupu

Komprimované streamy jdou stejnou cestou. Content stream s /Filter /FlateDecode (nebo jednoelementovým polem [/FlateDecode]) a bez prediktoru /DecodeParms se rozbalí ohraničeným dekodérem stropovaným na 32 MiB nebo MaxOutputBytes, podle toho, co je menší, zabalí a znovu zkomprimuje zpět na /FlateDecode s novým /Length. Stream, který by se rozbalil za strop, se odmítne místo zkrácení, což u souborů z nedůvěryhodných zdrojů záleží

Limity, které znát před opravou PDF/UA

EditPdfDocumentStructure je designem konzervativní a většina jeho limitů se projeví jako jasná Report.ErrorMessage místo potichu poškozeného souboru. Mějte to na paměti, když plánujete dávkovou opravu:

  • Zabalit se dají jen nefiltrované a prosté streamy FlateDecode; jiné filtry, nebo Flate s prediktorem, se odmítají
  • Content stream musí patřit /Contents přesně jedné stránky a Form XObjects, anotace a XObjects musí být použity přesně jednou stránkou
  • Wrapper vždycky používá tag /P; sémantická role pochází z /S strukturního elementu, ne z názvu tagu BDC
  • Synchronizace parent tree čte plochý kořen /Nums nebo jednu úroveň listů /Kids; hlubší mezilehlé uzly se nesestupují, takže takové soubory po editaci znovu zvalidujte
  • Soubory obsahující podpisy nebo podpisová pole se odmítají rovnou, takže podepisujte po opravě struktury, ne před ní

Validační krok není u opravovací pipeline volitelný. Pusťte validaci stromu struktury PDF/UA v PDFium Component před i po dávce a použijte page object API marked content pro čtení tagů BDC a MCID, když potřebujete potvrdit, které page objekty skončily uvnitř nových značek. Čistý parent tree vám říká, že se hledání řeší; neříká, že pořadí čtení nebo alternativní text je správný

FAQ

Přestavuje EditPdfDocumentStructure celý /ParentTree?

Ne. EditPdfDocumentStructure updatuje jen položky pro obsah, který označí v aktuální dávce, znovu používá stávající klíče /StructParents a alokuje nové tam, kde je potřeba, takže stávající položky parent tree zůstávají netknuté

Co se stane, když má PDF strom struktury, ale žádný /ParentTree?

Komponenta vytvoří indirect number tree << /Nums [] >>, odkáže ho z /StructTreeRoot a vloží první klíč pro označovaný obsah

Můžu označit obsah ve Form XObject sdíleném několika stránkami?

Ne touto operací. Sdílený form dělá /StmOwn nejednoznačným, takže psekAddMarkedContent vyžaduje, aby form odkazovala přesně jedna stránka, a jinak hlásí chybu

Čtení a editace stromu struktury, validace PDF/UA i přístup k marked content jsou součástí PDFium Component for Delphi and C++Builder, který obaluje PDFium pro projekty v Delphi, C++Builderu a Lazarusu