Technischer Artikel

/ParentTree und /StructParents in Delphi synchron halten

PDFium Component taggt bestehende PDF-Inhalte aus Delphi über EditPdfDocumentStructure in FPdfDocumentStructure.pas. Die Operationen psekAddMarkedContent und psekAddObjectReference tun mehr, als ein MCR oder OBJR an das /K eines Strukturelements anzuhängen: Sie allokieren auch den /StructParents- bzw. /StructParent-Schlüssel und schreiben den passenden /ParentTree-Eintrag — den umgekehrten Index, den ISO 32000-1 §14.7.4.4 verlangt

In dieser zweiten Hälfte geht das nachträgliche Taggen meist schief. Einen BDC-Wrapper und ein MCR zu ergänzen ist einfach, und ein Viewer, der den Strukturbaum von oben durchläuft, findet die neuen Inhalte gern. Unterstützende Technik geht oft den umgekehrten Weg: Sie startet bei einem Inhalt auf der Seite und fragt, welches Strukturelement ihn besitzt. Hat die Seite keinen /StructParents-Schlüssel, oder hat der /ParentTree keinen Eintrag für die neue MCID, scheitert diese Nachschlag, und Prüfer melden sie als fehlenden oder inkonsistenten Parent Tree. Die Datei sieht getaggt aus und funktioniert trotzdem nicht

Was mappt der /ParentTree tatsächlich?

Der /ParentTree ist ein Number Tree, der ganzzahlige Schlüssel auf Strukturelemente mappt, und die Schlüssel kommen aus dem Inhalt, nicht aus dem Baum. ISO 32000-1 §14.7.4.4 gibt jeder Seite oder jedem Form XObject, das Marked Content enthält, einen /StructParents-Integer, und der Parent-Tree-Wert unter diesem Schlüssel ist ein per MCID indiziertes Array: Eintrag 3 des Arrays ist das Strukturelement, dem <</MCID 3>> gehört. Eine Annotation oder ein als Ganzes als Inhaltsitem verwendetes XObject bekommt stattdessen einen einzelnen /StructParent-Schlüssel, und sein Parent-Tree-Wert ist das Strukturelement selbst, kein Array

Der von PDFium Component in Delphi geschriebene PDF/UA-Parent-Tree-Umgekehrte-Index: eine Inhaltsmarkierung mit MCID 3 löst über den Seiten-/StructParents-Schlüssel 17 in den /ParentTree-Number-Tree auf, dessen Array-Eintrag 3 das Strukturelement ist, dem das Marked Content gehört
Lesereihenfolge zählt für getaggtes PDF: Unterstützende Technik läuft von der MCID auf der Seite über /StructParents in das /ParentTree-Array, und ein fehlender Schlüssel oder Eintrag bricht diese Nachschlag, obwohl der Strukturbaum selbst in Ordnung aussieht

Das Containerformat ist der generische Number Tree aus §7.9.7. Ein Blatt hält /Nums, ein flaches Array aus Schlüssel-Wert-Paaren, sortiert in aufsteigender Schlüsselreihenfolge. Mittel- und Blattknoten unter der Wurzel tragen /Limits mit dem kleinsten und größten Schlüssel, den sie abdecken, während die Wurzel selbst kein /Limits haben darf. Obendrauf darf der Strukturbaum-Root /ParentTreeNextKey tragen (§14.7.2) — den nächsten Schlüssel, den ein Writer vergeben sollte. Jede Bearbeitung, die neuen Inhalt taggt, berührt also bis zu vier Objekte, die übereinstimmen müssen: den Content-Stream, das Seiten- oder Formular-Dictionary, das Parent-Tree-Blatt und den Strukturbaum-Root

Die Vorwärtsrichtung hat eigene Regeln, ausgeführt in §14.7.4.2 und §14.7.4.3. Eine Marked-Content-Reference (MCR) für Inhalt im eigenen Content-Stream der Seite braucht nur /Pg und /MCID. Lebt das Marked Content innerhalb eines Form XObjects, braucht die MCR zusätzlich /Stm, um diesen Stream zu nennen, denn MCIDs werden pro Content-Stream nummeriert. Eine Object-Reference (OBJR) benennt die Annotation oder das XObject mit /Obj und ihre Seite mit /Pg

Einen Content-Stream mit EditPdfDocumentStructure taggen

EditPdfDocumentStructure ist ein snapshot-gesicherter Batch-Editor. Zuerst rufen Sie ReadPdfDocumentStructure auf, um einen TPdfDocumentStructureSnapshot zu bekommen, dann reichen Sie dessen SourceSha256 zusammen mit einem Array aus TPdfStructureEditOperation-Records zurück. Haben sich die Quellbytes dazwischen geändert, wird die Bearbeitung ohne Ausgabe abgewiesen. Bestehende Elemente werden über ihre Objektnummer in NodeID adressiert, und 0 bezeichnet den Strukturbaum-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;            // bestehendes Element, über Objektnummer
    Ops[0].TargetObjectNumber := StreamObj; // Seiten-Content-Stream oder Seiten-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;

Für diese eine Operation hüllt EditPdfDocumentStructure den Zielstream in /P <</MCID n>> BDC ... EMC, hängt das MCR an das /K des Elements an, gibt der Seite (oder dem Formular) einen /StructParents-Schlüssel, falls keiner vorhanden ist, und schreibt Element n in das Parent-Tree-Array für diesen Schlüssel. Das Ergebnis wird als inkrementelles Update nach den Originalbytes geschrieben, und Report.CreatedMarkedContents verrät, wie viele MCRs erzeugt wurden. Enthält der Stream bereits ausgeglichene BDC/EMC-Paare, wird nur der ungetaggte Schwanz nach dem letzten Top-Level-EMC gehüllt; sonst der ganze Stream

Wie werden neue MCIDs und Parent-Tree-Schlüssel gewählt?

Eine neue MCID ist das aktuelle Maximum plus eins, und ein neuer Parent-Tree-Schlüssel ist Max(MaxKey + 1, ParentTreeNextKey). Für die MCID nimmt EditPdfDocumentStructure das Maximum über die bereits für diese Seite (oder dieses Form XObject) verzeichneten MCRs und jede /MCID, die sie in den eigenen Property-Listen des Streams findet. Beides zu zählen zählt: Ein Strukturbaum kann ein MCR verloren haben, während der Inhalt die Markierung noch trägt, und diese Nummer wiederzuverwenden würde zwei Elemente denselben Inhalt beanspruchen lassen

Die Schlüssellogik folgt derselben defensiven Idee. Hat der Besitzer bereits einen /StructParents-Wert, wird das bestehende Array am Index MCID erweitert, mit null in allen Lücken, und kein neuer Schlüssel allokiert. Falls nicht, durchsucht die Komponente jedes /Nums-Blatt nach dem höchsten Schlüssel, vergleicht ihn mit /ParentTreeNextKey und nimmt den größeren. Ein veraltetes /ParentTreeNextKey unterhalb bereits verwendeter Schlüssel wie eines, das Schlüssel jenseits des letzten Eintrags reserviert, werden beide abgedeckt, denn der größere der beiden Werte ist stets frei. Nach der Allokation wird /ParentTreeNextKey auf den neuen Schlüssel plus eins vorgestellt

MCID- und Parent-Tree-Schlüsselallokation im PDFium-Component-Struktureditor für Delphi: die neue MCID liegt eins über dem in MCRs und Stream-Property-Listen bereits verzeichneten Maximum, und der Schlüssel ist der größere aus höchstem /Nums-Schlüssel plus eins und /ParentTreeNextKey, mit null in Array-Lücken
EditPdfDocumentStructure leitet die neue MCID sowohl aus MCRs als auch aus In-Stream-Markierungen ab und allokiert den Parent-Tree-Schlüssel defensiv, sodass ein veraltetes /ParentTreeNextKey oder eine wiederverwendete MCID nicht zwei Elemente denselben Inhalt beanspruchen lässt

Das neue Paar wird in aufsteigender Ordnung in /Nums eingefügt statt angehängt, denn Number-Tree-Nachschlagevorgänge beruhen auf dieser Ordnung. Trägt das Blatt /Limits, werden die Grenzen aus dem umgeschriebenen Array neu berechnet. Hat der Strukturbaum-Root überhaupt keinen /ParentTree — häufig bei Dateien, die von Werkzeugen getaggt wurden, die nur den Vorwärtsbaum schrieben —, synthetisiert EditPdfDocumentStructure ein neues indirektes << /Nums [] >>-Objekt und verlinkt es von /StructTreeRoot, bevor der erste Eintrag eingefügt wird

Form XObjects, /StmOwn und Object-References

Marked Content innerhalb eines Form XObjects wird nur unterstützt, wenn genau eine Seite dieses Formular verwendet. Die MCR, die EditPdfDocumentStructure für ein Formular schreibt, trägt /Pg, /Stm mit dem Formular-Stream und /StmOwn mit der Seite, und der /StructParents-Schlüssel geht ans Formular-Dictionary statt an die Seite. Ein von drei Seiten gezeichnetes Formular hat drei plausible Besitzer, also wäre jedes einzelne /StmOwn für zwei davon falsch. Die Komponente weist die Bearbeitung mit einem Fehler ab, statt zu raten

Ein-Besitzer-Regel für das Taggen von Form-XObject-Inhalten mit PDFium Component in Delphi: ein MCR an einem von einer einzigen Seite verwendeten Formular trägt /Pg, /Stm und /StmOwn mit /StructParents am Formular-Dictionary, während ein von drei Seiten gezeichnetes Formular /StmOwn mehrdeutig macht und die Bearbeitung abgewiesen wird
Der /StmOwn-Schlüssel ergibt nur Sinn, wenn eine Seite das Formular besitzt — deshalb werden geteilte Formulare und geteilte Annotationen abgewiesen, statt mit einem ratbaren Besitzer getaggt zu werden

Dieselbe Eindeutigkeitsregel gilt für psekAddObjectReference. Das Ziel muss eine Annotation sein, die in den /Annots genau einer Seite erscheint, oder ein Form- oder Image-XObject, das in den Ressourcen genau einer Seite erscheint; alles andere wird abgewiesen. Das OBJR bekommt /Obj und /Pg, das Ziel bekommt einen /StructParent-Schlüssel (wiederverwendet, falls vorhanden), und der Parent-Tree-Wert ist eine schlichte Referenz auf das Element. Das ist die übliche Reparatur für eine Link-Annotation oder ein Figure-Bild, das visuell getaggt, aber nie an den Baum angebunden wurde

// Ein ungetagtes Bild in ein neues Figure-Element hüllen, es dann zweifach binden:
// als OBJR für das Image-XObject und als Marked Content auf der Seite
SetLength(Ops, 3);
Ops[0] := Default(TPdfStructureEditOperation);
Ops[0].Kind := psekInsertContainer;
Ops[0].NodeID := -1;              // temporäre ID, muss negativ und eindeutig sein
Ops[0].ParentNodeID := SectID;    // bestehendes Sect-Element
Ops[0].Value := 'Figure';         // Standardrolle oder ein RoleMap-Eintrag

Ops[1] := Default(TPdfStructureEditOperation);
Ops[1].Kind := psekAddObjectReference;
Ops[1].NodeID := -1;              // verweist auf das oben erzeugte Container-Element
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]));

Negative NodeID-Werte benennen Container, die früher in demselben Batch erzeugt wurden, und Report.InsertedNodes mappt jede angeforderte ID auf die Objektnummer, die sie erhalten hat. psekInsertContainer akzeptiert Standardrollen aus PDF 1.7, PDF 2.0 und PDF/UA wie P, H1 bis H6, Figure, Table oder LI, plus eigene Rollen, die bereits in /RoleMap gemappt sind. BeforeNodeID an den beiden Inhaltsoperationen zu setzen platziert das neue MCR oder OBJR vor einem bestehenden Kind statt am Ende

Warum braucht das doppelte Bearbeiten desselben Streams in einem Batch Sorgfalt?

Ein Batch kann dasselbe Objekt wiederholt treffen, und jede spätere Operation muss das Ergebnis der früheren sehen. Zwei psekAddMarkedContent-Operationen gegen dieselbe Seite schreiben beide ihren Content-Stream neu, und beide aktualisieren dasselbe Parent-Tree-Blatt. Eine Implementierung, die jede Bearbeitung als separatem angehängtes Objekt staffelt, schreibt zwei Versionen von Objekt 12 in das Update. Je nachdem, welche der Reader behält, verlieren Sie den ersten BDC-Wrapper oder das erste /Nums-Paar, und die zweite MCID kann mit der ersten kollidieren, weil sie aus veralteten Bytes berechnet wurde

EditPdfDocumentStructure verschlüsselt gestaffelte Updates nach (ObjectNumber, Generation) und überschreibt einen bestehenden Eintrag an Ort und Stelle, und es aktualisiert die In-Memory-Kopie des Objekts gleichzeitig. Die zweite Operation parst daher den bereits gehüllten Stream, sieht MCID 0, allokert MCID 1 und erweitert dasselbe Parent-Tree-Array. Ein Objekt, eine finale Version. Bevor irgendetwas zurückgegeben wird, wird die Ausgabe zurückgelesen und mit dem In-Memory-Modell verglichen: Elementzahl, Inhaltsitem-Zahlen und jedes behaltene MCR oder OBJR müssen übereinstimmen, sonst scheitert der Aufruf ohne Ausgabe

Komprimierte Streams folgen demselben Pfad. Ein Content-Stream mit /Filter /FlateDecode (oder einem Ein-Element-[/FlateDecode]-Array) und ohne /DecodeParms-Predictor wird mit einem begrenzten Decoder entpackt, gekappt bei 32 MiB oder MaxOutputBytes, je nachdem, was kleiner ist, gehüllt und zurückkomprimiert zu /FlateDecode mit neuer /Length. Ein Stream, der über die Kappe hinaus entpacken würde, wird abgewiesen statt abgeschnitten — wichtig bei Dateien aus nicht vertrauenswürdigen Quellen

Grenzen, die man vor einem PDF/UA-Reparaturlauf kennen sollte

EditPdfDocumentStructure ist dem Design nach konservativ, und die meisten seiner Grenzen zeigen sich als klare Report.ErrorMessage statt als still zerbrochene Datei. Halten Sie sich diese vor einem Batch-Repair vor Augen:

  • Nur ungefilterte und schlichte FlateDecode-Streams lassen sich hüllen; andere Filter oder Flate mit Predictor werden abgewiesen
  • Ein Content-Stream muss zu den /Contents genau einer Seite gehören, und Form XObjects, Annotationen und XObjects müssen von genau einer Seite verwendet werden
  • Der Wrapper verwendet stets das /P-Tag; die semantische Rolle kommt aus dem /S des Strukturelements, nicht aus dem BDC-Tag-Namen
  • Die Parent-Tree-Synchronisierung liest eine flache /Nums-Wurzel oder eine Ebene von /Kids-Blättern; tiefere Mittelknoten werden nicht hinabgestiegen, revalidieren Sie solche Dateien also nach der Bearbeitung
  • Dateien mit Signaturen oder Signaturfeldern werden rundheraus abgewiesen, signieren Sie also nach der Struktur-Reparatur, nicht davor

Der Validierungsschritt ist für eine Reparatur-Pipeline nicht optional. Fahren Sie die PDF/UA-Strukturbaum-Validierung in PDFium Component vor und nach dem Batch, und verwenden Sie die Page-Object-Marked-Content-API zum Lesen von BDC-Tags und MCIDs, wenn Sie bestätigen müssen, welche Page-Objects in den neuen Markierungen gelandet sind. Ein sauberer Parent Tree sagt Ihnen, dass Nachschlagevorgänge auflösen; er sagt nicht, dass Lesereihenfolge oder Alternativtext stimmen

FAQ

Baut EditPdfDocumentStructure den ganzen /ParentTree neu auf?

Nein. EditPdfDocumentStructure aktualisiert nur die Einträge für Inhalte, die es im aktuellen Batch taggt, verwendet bestehende /StructParents-Schlüssel wieder und allokert neue nur bei Bedarf, bestehende Parent-Tree-Einträge bleiben also unberührt

Was passiert, wenn das PDF einen Strukturbaum, aber keinen /ParentTree hat?

Die Komponente erzeugt einen indirekten << /Nums [] >>-Number-Tree, verlinkt ihn von /StructTreeRoot und fügt den ersten Schlüssel für den zu taggenden Inhalt ein

Kann ich Inhalte in einem von mehreren Seiten geteilten Form XObject taggen?

Nicht mit dieser Operation. Ein geteiltes Formular macht /StmOwn mehrdeutig, also verlangt psekAddMarkedContent, dass genau eine Seite das Formular referenziert, und meldet sonst einen Fehler

Strukturbaum-Lesen und -Bearbeiten, PDF/UA-Validierung und Marked-Content-Zugriff sind Teil des PDFium Component for Delphi and C++Builder, das PDFium für Delphi-, C++Builder- und Lazarus-Projekte umhüllt