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
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
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
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
/Contentsgenau 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/Sdes Strukturelements, nicht aus demBDC-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