Technisch artikel

/ParentTree en /StructParents synchroon houden in Delphi

PDFium Component tagt bestaande PDF-content vanuit Delphi via EditPdfDocumentStructure in FPdfDocumentStructure.pas. De operaties psekAddMarkedContent en psekAddObjectReference doen meer dan een MCR of OBJR achteraan de /K van een structuurelement plakken: ze wijzen ook de sleutel /StructParents of /StructParent toe en schrijven de matchende /ParentTree-entry, de omgekeerde index die ISO 32000-1 §14.7.4.4 vereist

Die tweede helft is waar het met terugwerkende kracht aanbrengen van tags doorgaans misgaat. Een wikkel BDC en een MCR toevoegen is makkelijk, en een viewer die de structuurboom vanaf de top doorloopt vindt de nieuwe content vrolijk. Assisterende technologie gaat vaak de andere kant op: hij vertrekt vanuit een stuk content op de pagina en vraagt welk structuurelement hem bezit. Heeft de pagina geen sleutel /StructParents, of heeft de /ParentTree geen entry voor de nieuwe MCID, dan faalt die opzoeking, en checkers melden het als een ontbrekende of inconsistente parent tree. Het bestand lijkt getagd en werkt toch niet

Wat mapped /ParentTree werkelijk?

De /ParentTree is een number tree die integersleutels op structuurelementen afbeeldt, en de sleutels komen uit de content, niet uit de boom. ISO 32000-1 §14.7.4.4 geeft elke pagina of Form XObject die marked content bevat een integer /StructParents, en de parent tree-waarde onder die sleutel is een array geïndexeerd op MCID: entry 3 van de array is het structuurelement dat <</MCID 3>> bezit. Een annotatie of XObject die als geheel content-item wordt gebruikt krijgt in plaats daarvan een enkele sleutel /StructParent, en zijn parent tree-waarde is het structuurelement zelf, geen array

Omgekeerde index van de PDF/UA parent tree geschreven door PDFium Component in Delphi: een contentmark met MCID 3 resolveert via de pagina-sleutel /StructParents 17 in de number tree /ParentTree, waarvan array-entry 3 het structuurelement is dat de marked content bezit
Leesvolgorde telt voor getagde PDF: assisterende technologie loopt van de MCID op de pagina via /StructParents naar de /ParentTree-array, en een ontbrekende sleutel of entry breekt die opzoeking ook al oogt de structuurboom zelf prima

Het containerformaat is de generieke number tree uit §7.9.7. Een leaf bevat /Nums, een platte array van sleutel-waarde-paren gesorteerd in oplopende sleutelvolgorde. Tussen- en leafknooppunten onder de root dragen /Limits met de kleinste en grootste sleutel die ze dekken, terwijl de root zelf geen /Limits mag hebben. Daarbovenop kan de structuurboom-root /ParentTreeNextKey dragen (§14.7.2), de volgende sleutel die een writer zou moeten uitgeven. Elke bewerking die nieuwe content tagt raakt daarom tot vier objecten die het moeten eens zijn: de content stream, de pagina- of formdictionary, de parent tree-leaf, en de structuurboom-root

De vooruitse richting heeft eigen regels, uiteengezet in §14.7.4.2 en §14.7.4.3. Een marked-content reference (MCR) voor content in de eigen content stream van de pagina heeft alleen /Pg en /MCID nodig. Woont de marked content binnen een Form XObject, dan heeft de MCR ook /Stm nodig om die stream te benoemen, want MCIDs worden per content stream genummerd. Een object reference (OBJR) benoemt de annotatie of het XObject met /Obj en zijn pagina met /Pg

Een content stream taggen met EditPdfDocumentStructure

EditPdfDocumentStructure is een batcheditor met snapshot-bewaking. U roept eerst ReadPdfDocumentStructure aan om een TPdfDocumentStructureSnapshot te krijgen, en geeft daarna zijn SourceSha256 terug samen met een array van records TPdfStructureEditOperation. Zijn de bronbytes intussen veranderd, dan wordt de bewerking geweigerd zonder output. Bestaande elementen worden benaderd bij hun objectnummer in NodeID, en 0 betekent de structuurboom-root

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;            // bestaand element, op objectnummer
    Ops[0].TargetObjectNumber := StreamObj; // pagina content stream of pagina Form XObject
    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;

Voor die ene operatie wikkelt EditPdfDocumentStructure de doelstream in /P <</MCID n>> BDC ... EMC, plakt hij de MCR achteraan de /K van het element, geeft hij de pagina (of het form) een sleutel /StructParents als hij er geen heeft, en schrijft hij element n in de parent tree-array voor die sleutel. Het resultaat wordt als incrementele update achter de originele bytes weggeschreven, en Report.CreatedMarkedContents vertelt u hoeveel MCRs er zijn aangemaakt. Bevat de stream al uitgebalanceerde paren BDC/EMC, dan wordt alleen de ongetagde staart na de laatste top-level EMC gewikkeld; anders de hele stream

Hoe worden nieuwe MCIDs en parent tree-sleutels gekozen?

Een nieuwe MCID is het huidige maximum plus één, en een nieuwe parent tree-sleutel is Max(MaxKey + 1, ParentTreeNextKey). Voor de MCID neemt EditPdfDocumentStructure het maximum over de MCRs die al voor die pagina (of dat Form XObject) zijn vastgelegd en elke /MCID die hij in de eigen property lists van de stream vindt. Beide tellen telt: een structuurboom kan een MCR kwijt zijn terwijl de content de mark nog draagt, en dat nummer hergebruiken zou twee elementen dezelfde content laten claimen

De sleutelogica volgt hetzelfde defensieve idee. Heeft de eigenaar al een waarde /StructParents, dan wordt de bestaande array uitgebreid bij index MCID, met null in elke gap, en wordt geen nieuwe sleutel toegewezen. Zo niet, dan scant de component elke leaf /Nums op de hoogste sleutel, vergelijkt die met /ParentTreeNextKey en neemt de grootste. Een verouderde /ParentTreeNextKey lager dan al gebruikte sleutels en een die sleutels voorbij de laatste entry reserveert worden allebei afgehandeld, want de grootste van de twee waarden is altijd vrij. Na toewijzing wordt /ParentTreeNextKey opgeschoven naar de nieuwe sleutel plus één

MCID- en parent tree-sleuteltoewijzing in de PDFium Component structuureditor voor Delphi: de nieuwe MCID is één boven het maximum dat al in MCRs en stream property lists is vastgelegd, en de sleutel is de grootste van de hoogste /Nums-sleutel plus één en /ParentTreeNextKey, met null in arraygaten
EditPdfDocumentStructure leidt de nieuwe MCID af uit zowel MCRs als marks in de stream en wijst de parent tree-sleutel defensief toe, zodat een verouderde /ParentTreeNextKey of een hergebruikte MCID twee elementen niet dezelfde content kan laten claimen

Het nieuwe paar wordt in oplopende volgorde in /Nums ingevoegd in plaats van achteraan geplakt, want number tree-opzoekingen leunen op die volgorde. Draagt de leaf /Limits, dan worden de grenzen herberekend uit de herschreven array. Heeft de structuurboom-root helemaal geen /ParentTree, wat vaak voorkomt in bestanden getagd door tools die alleen de vooruitse boom schreven, dan synthetiseert EditPdfDocumentStructure een nieuw indirect object << /Nums [] >> en linkt hij het vanuit /StructTreeRoot voordat hij de eerste entry invoegt

Form XObjects, /StmOwn en objectverwijzingen

Marked content binnen een Form XObject wordt alleen ondersteund wanneer dat form door precies één pagina wordt gebruikt. De MCR die EditPdfDocumentStructure voor een form schrijft draagt /Pg, /Stm die de formstream benoemt, en /StmOwn die de pagina benoemt, en de sleutel /StructParents gaat op de formdictionary in plaats van op de pagina. Een door drie pagina's getekend form heeft drie plausibele eigenaren, dus elke enkele /StmOwn zou voor twee van hen verkeerd zijn. De component weigert de bewerking met een fout in plaats van te gokken

Eén-eigenaar-regel voor het taggen van Form XObject-content met PDFium Component in Delphi: een MCR op een door één pagina gebruikt form draagt /Pg, /Stm en /StmOwn met /StructParents op de formdictionary, terwijl een door drie pagina's getekend form /StmOwn ambigu maakt en de bewerking wordt geweigerd
De sleutel /StmOwn is alleen zinvol wanneer één pagina het form bezit, en daarom worden gedeelde forms en gedeelde annotaties geweigerd in plaats van getagd met een gisbare eigenaar

Dezelfde uniciteitsregel geldt voor psekAddObjectReference. Het doel moet een annotatie zijn die in de /Annots van precies één pagina verschijnt, of een Form of Image XObject die in de resources van precies één pagina verschijnt; al het andere wordt geweigerd. De OBJR krijgt /Obj en /Pg, het doel krijgt een sleutel /StructParent (hergebruikt indien aanwezig), en de parent tree-waarde is een kale verwijzing naar het element. Dit is de gebruikelijke fix voor een linkannotatie of een figure-image die visueel was getagd maar nooit met de boom was verbonden

// Wikkel een ongetagde image in een nieuw Figure-element en bind hem dan op twee manieren:
// als een OBJR voor de image XObject en als marked content op de pagina
SetLength(Ops, 3);
Ops[0] := Default(TPdfStructureEditOperation);
Ops[0].Kind := psekInsertContainer;
Ops[0].NodeID := -1;              // tijdelijke ID, moet negatief en uniek zijn
Ops[0].ParentNodeID := SectID;    // bestaand Sect-element
Ops[0].Value := 'Figure';         // standaardrol of een RoleMap-entry

Ops[1] := Default(TPdfStructureEditOperation);
Ops[1].Kind := psekAddObjectReference;
Ops[1].NodeID := -1;              // verwijst naar het hierboven aangemaakte container
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]));

Negatieve waarden NodeID benoemen containers eerder in dezelfde batch aangemaakt, en Report.InsertedNodes mapt elke gevraagde ID op het objectnummer dat hij kreeg. psekInsertContainer accepteert standaardrollen PDF 1.7, PDF 2.0 en PDF/UA zoals P, H1 tot H6, Figure, Table of LI, plus custom rollen die al in /RoleMap zijn gemapped. BeforeNodeID zetten op de twee contentoperaties plaatst de nieuwe MCR of OBJR vóór een bestaand kind in plaats van op het eind

Waarom vraagt het tweemaal bewerken van dezelfde stream in één batch zorgvuldigheid?

Een batch kan hetzelfde object herhaaldelijk raken, en elke latere operatie moet het resultaat van de eerdere zien. Twee operaties psekAddMarkedContent tegen dezelfde pagina herschrijven allebei zijn content stream en werken allebei dezelfde parent tree-leaf bij. Een implementatie die elke bewerking als een apart toegevoegd object opstapelt schrijft twee versies van object 12 in de update. Afhankelijk van welke de reader houdt, verliest u de eerste wikkel BDC of het eerste paar /Nums, en de tweede MCID kan met de eerste botsen omdat hij uit verouderde bytes is berekend

EditPdfDocumentStructure sleutelt gestagede updates op (ObjectNumber, Generation) en overschrijft een bestaande entry op zijn plek, en hij verversert tegelijk de in-memory kopie van het object. De tweede operatie parst dus de al gewikkelde stream, ziet MCID 0, wijst MCID 1 toe en breidt dezelfde parent tree-array uit. Eén object, één definitieve versie. Voordat er ook maar iets wordt teruggegeven, wordt de output teruggelezen en vergeleken met het in-memory model: elementaantal, content item-aantallen en elke vastgehouden MCR of OBJR moeten matchen, of de aanroep faalt zonder output

Gecomprimeerde streams volgen hetzelfde pad. Een content stream met /Filter /FlateDecode (of een array van één element [/FlateDecode]) en geen /DecodeParms-predictor wordt geïnflateerd met een begrensde decoder gekapt op 32 MiB of MaxOutputBytes, wat het kleinste is, gewikkeld en opnieuw gecomprimeerd naar /FlateDecode met een nieuwe /Length. Een stream die voorbij de kap zou infleren wordt geweigerd in plaats van ingekort, wat telt voor bestanden van onbetrouwbare bronnen

Grenzen die het kennen waard zijn vóór een PDF/UA-herstelrun

EditPdfDocumentStructure is van ontwerp conservatief, en de meeste grenzen ervan komen naar boven als een duidelijke Report.ErrorMessage in plaats van als een stilletjes gebroken bestand. Houd deze vast wanneer u een batchherstel plant:

  • Alleen ongefilterde en kale FlateDecode-streams kunnen worden gewikkeld; andere filters, of Flate met een predictor, worden geweigerd
  • Een content stream moet tot de /Contents van precies één pagina behoren, en Form XObjects, annotaties en XObjects moeten door precies één pagina worden gebruikt
  • De wikkel gebruikt altijd de tag /P; de semantische rol komt van de /S van het structuurelement, niet van de tagnaam BDC
  • De parent tree-sync leest een platte /Nums-root of één niveau van /Kids-leaves; diepere tussennodes worden niet afgedaald, dus valideer zulke bestanden na bewerking opnieuw
  • Bestanden die handtekeningen of handtekeningvelden bevatten worden ronduit geweigerd, dus teken na het structuurherstel, niet ervoor

De validatiestap is geen optie voor een herstelpipeline. Draai de PDF/UA-structuurboomvalidatie in PDFium Component vóór en na de batch, en gebruik de page object marked-content-API voor het lezen van BDC-tags en MCIDs wanneer u moet bevestigen welke page objects in de nieuwe marks zijn beland. Een schone parent tree vertelt u dat opzoekingen resolven; hij vertelt u niet dat de leesvolgorde of de alternate text klopt

FAQ

Herbouwt EditPdfDocumentStructure de hele /ParentTree?

Nee. EditPdfDocumentStructure werkt alleen de entries bij voor content die hij in de huidige batch tagt, hergebruikt bestaande sleutels /StructParents en wijst waar nodig nieuwe toe, dus bestaande parent tree-entries blijven onaangeroerd

Wat gebeurt er als de PDF een structuurboom heeft maar geen /ParentTree?

De component maakt een indirecte number tree << /Nums [] >> aan, linkt hem vanuit /StructTreeRoot en voegt de eerste sleutel in voor de content die wordt getagd

Kan ik content taggen in een Form XObject dat door meerdere pagina's wordt gedeeld?

Niet met deze operatie. Een gedeeld form maakt /StmOwn ambigu, dus psekAddMarkedContent eist dat het form door precies één pagina wordt gerefereerd en meldt anders een fout

Structuurboom lezen en bewerken, PDF/UA-validatie en marked-content-toegang maken deel uit van de PDFium Component for Delphi and C++Builder, die PDFium omwikkelt voor Delphi-, C++Builder- en Lazarus-projecten