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
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
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í
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
/Contentspř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/Sstrukturního elementu, ne z názvu taguBDC - Synchronizace parent tree čte plochý kořen
/Numsnebo 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