PDFium Component tags existing PDF content from Delphi through EditPdfDocumentStructure in FPdfDocumentStructure.pas. The psekAddMarkedContent and psekAddObjectReference operations do more than append an MCR or OBJR to a structure element's /K: they also allocate the /StructParents or /StructParent key and write the matching /ParentTree entry, which is the reverse index that ISO 32000-1 §14.7.4.4 requires
That second half is where retrofitting tags usually goes wrong. Adding a BDC wrapper and an MCR is easy, and a viewer that walks the structure tree from the top will happily find the new content. Assistive technology often goes the other way, though: it starts from a piece of content on the page and asks which structure element owns it. If the page has no /StructParents key, or the /ParentTree has no entry for the new MCID, that lookup fails, and checkers report it as a missing or inconsistent parent tree. The file looks tagged and still does not work
What does /ParentTree actually map?
The /ParentTree is a number tree that maps integer keys to structure elements, and the keys come from content, not from the tree. ISO 32000-1 §14.7.4.4 gives every page or Form XObject that contains marked content a /StructParents integer, and the parent tree value under that key is an array indexed by MCID: entry 3 of the array is the structure element that owns <</MCID 3>>. An annotation or XObject used as a whole content item gets a single /StructParent key instead, and its parent tree value is the structure element itself, not an array
The container format is the generic number tree from §7.9.7. A leaf holds /Nums, a flat array of key and value pairs sorted in ascending key order. Intermediate and leaf nodes under the root carry /Limits with the smallest and largest key they cover, while the root itself must not have /Limits. On top of that, the structure tree root may carry /ParentTreeNextKey (§14.7.2), the next key a writer should hand out. Every edit that tags new content therefore touches up to four objects that have to agree: the content stream, the page or form dictionary, the parent tree leaf, and the structure tree root
The forward direction has its own rules, set out in §14.7.4.2 and §14.7.4.3. A marked-content reference (MCR) for content in the page's own content stream needs only /Pg and /MCID. When the marked content lives inside a Form XObject, the MCR also needs /Stm to name that stream, because MCIDs are numbered per content stream. An object reference (OBJR) names the annotation or XObject with /Obj and its page with /Pg
Tagging a content stream with EditPdfDocumentStructure
EditPdfDocumentStructure is a snapshot-guarded batch editor. You first call ReadPdfDocumentStructure to get a TPdfDocumentStructureSnapshot, then pass its SourceSha256 back together with an array of TPdfStructureEditOperation records. If the source bytes changed in between, the edit is refused with no output. Existing elements are addressed by their object number in NodeID, and 0 denotes the structure tree 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; // existing element, by object number
Ops[0].TargetObjectNumber := StreamObj; // page content stream or page 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;
For that one operation, EditPdfDocumentStructure wraps the target stream in /P <</MCID n>> BDC ... EMC, appends the MCR to the element's /K, gives the page (or the form) a /StructParents key if it has none, and writes element n into the parent tree array for that key. The result is written as an incremental update after the original bytes, and Report.CreatedMarkedContents tells you how many MCRs were created. When the stream already contains balanced BDC/EMC pairs, only the untagged tail after the last top-level EMC is wrapped; otherwise the whole stream is
How are new MCIDs and parent tree keys chosen?
A new MCID is the current maximum plus one, and a new parent tree key is Max(MaxKey + 1, ParentTreeNextKey). For the MCID, EditPdfDocumentStructure takes the maximum across the MCRs already recorded for that page (or that Form XObject) and every /MCID it finds in the stream's own property lists. Counting both matters: a structure tree may have lost an MCR while the content still carries the mark, and reusing that number would make two elements claim the same content
The key logic follows the same defensive idea. If the owner already has a /StructParents value, the existing array is extended at index MCID, with null filling any gap, and no new key is allocated. If not, the component scans every /Nums leaf for the highest key, compares it with /ParentTreeNextKey, and takes the larger. A stale /ParentTreeNextKey lower than keys already in use and one that reserves keys past the last entry are both handled, because the larger of the two values is always free. After allocation, /ParentTreeNextKey is advanced to the new key plus one
The new pair is inserted into /Nums in ascending order rather than appended, because number tree lookups rely on that order. If the leaf carries /Limits, the bounds are recomputed from the rewritten array. When the structure tree root has no /ParentTree at all, which is common in files tagged by tools that only wrote the forward tree, EditPdfDocumentStructure synthesizes a new indirect << /Nums [] >> object and links it from /StructTreeRoot before inserting the first entry
Form XObjects, /StmOwn and object references
Marked content inside a Form XObject is supported only when that form is used by exactly one page. The MCR that EditPdfDocumentStructure writes for a form carries /Pg, /Stm naming the form stream, and /StmOwn naming the page, and the /StructParents key goes on the form dictionary rather than on the page. A form drawn by three pages has three plausible owners, so any single /StmOwn would be wrong for two of them. The component refuses the edit with an error instead of guessing
The same uniqueness rule applies to psekAddObjectReference. The target must be an annotation that appears in exactly one page's /Annots, or a Form or Image XObject that appears in exactly one page's resources; anything else is rejected. The OBJR gets /Obj and /Pg, the target gets a /StructParent key (reused if present), and the parent tree value is a plain reference to the element. This is the usual fix for a link annotation or a figure image that was tagged visually but never connected to the tree
// Wrap an untagged image in a new Figure element, then bind it two ways:
// as an OBJR for the image XObject and as marked content on the page
SetLength(Ops, 3);
Ops[0] := Default(TPdfStructureEditOperation);
Ops[0].Kind := psekInsertContainer;
Ops[0].NodeID := -1; // temporary ID, must be negative and unique
Ops[0].ParentNodeID := SectID; // existing Sect element
Ops[0].Value := 'Figure'; // standard role or a RoleMap entry
Ops[1] := Default(TPdfStructureEditOperation);
Ops[1].Kind := psekAddObjectReference;
Ops[1].NodeID := -1; // refers to the container created above
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 values name containers created earlier in the same batch, and Report.InsertedNodes maps each requested ID to the object number it received. psekInsertContainer accepts standard PDF 1.7, PDF 2.0 and PDF/UA roles such as P, H1 to H6, Figure, Table or LI, plus custom roles already mapped in /RoleMap. Setting BeforeNodeID on the two content operations places the new MCR or OBJR before an existing child instead of at the end
Why does editing the same stream twice in one batch need care?
A batch can hit the same object repeatedly, and every later operation has to see the result of the earlier ones. Two psekAddMarkedContent operations against the same page both rewrite its content stream, and both update the same parent tree leaf. An implementation that stages each edit as a separate appended object writes two versions of object 12 in the update. Depending on which one the reader keeps, you lose the first BDC wrapper or the first /Nums pair, and the second MCID can collide with the first because it was computed from stale bytes
EditPdfDocumentStructure keys staged updates by (ObjectNumber, Generation) and overwrites an existing entry in place, and it refreshes the in-memory copy of the object at the same time. The second operation therefore parses the already wrapped stream, sees MCID 0, allocates MCID 1, and extends the same parent tree array. One object, one final version. Before anything is returned, the output is read back and compared with the in-memory model: element count, content item counts and every retained MCR or OBJR must match, or the call fails without output
Compressed streams follow the same path. A content stream with /Filter /FlateDecode (or a one-element [/FlateDecode] array) and no /DecodeParms predictor is inflated with a bounded decoder capped at 32 MiB or MaxOutputBytes, whichever is smaller, wrapped, and recompressed back to /FlateDecode with a new /Length. A stream that would inflate past the cap is rejected rather than truncated, which matters for files from untrusted sources
Limits worth knowing before a PDF/UA repair run
EditPdfDocumentStructure is conservative by design, and most of its limits surface as a clear Report.ErrorMessage rather than as a quietly broken file. Keep these in mind when you plan a batch repair:
- Only unfiltered and plain
FlateDecodestreams can be wrapped; other filters, or Flate with a predictor, are refused - A content stream must belong to exactly one page's
/Contents, and Form XObjects, annotations and XObjects must be used by exactly one page - The wrapper always uses the
/Ptag; the semantic role comes from the structure element's/S, not from theBDCtag name - The parent tree sync reads a flat
/Numsroot or one level of/Kidsleaves; deeper intermediate nodes are not descended, so re-validate such files after editing - Files that contain signatures or signature fields are refused outright, so sign after the structure repair, not before
The validation step is not optional for a repair pipeline. Run the PDF/UA structure tree validation in PDFium Component before and after the batch, and use the page object marked-content API for reading BDC tags and MCIDs when you need to confirm which page objects ended up inside the new marks. A clean parent tree tells you lookups resolve; it does not tell you the reading order or the alternate text is right
FAQ
Does EditPdfDocumentStructure rebuild the whole /ParentTree?
No. EditPdfDocumentStructure updates only the entries for content it tags in the current batch, reusing existing /StructParents keys and allocating new ones where needed, so existing parent tree entries stay untouched
What happens if the PDF has a structure tree but no /ParentTree?
The component creates an indirect << /Nums [] >> number tree, links it from /StructTreeRoot, and inserts the first key for the content being tagged
Can I tag content in a Form XObject shared by several pages?
Not with this operation. A shared form makes /StmOwn ambiguous, so psekAddMarkedContent requires the form to be referenced by exactly one page and reports an error otherwise
Structure tree reading and editing, PDF/UA validation and marked-content access are part of the PDFium Component for Delphi and C++Builder, which wraps PDFium for Delphi, C++Builder, and Lazarus projects