Technical Article

Reading and Writing PDF Marked Content in Delphi

In a tagged PDF, the running header at the top of each page usually sits inside /Artifact <</Type /Pagination>> BDC … EMC, while the first paragraph under it sits inside /P <</MCID 0>> BDC … EMC. Both are marked content in the sense of ISO 32000-1 §14.6. Only the paragraph is reachable from the structure tree, because only the paragraph carries a marked-content identifier, and that one integer is what ties painted glyphs to a structure element such as /H1 or /P. Code that audits or repairs tagging therefore has to read three things off every page object (the tag, its property list and the MCID) and then check the number against the MCIDs the structure elements on that page actually claim

Reading BDC marks and property lists from Delphi

In PDFium Component, TPdf.PageObjectMarks(Index) returns every mark that encloses the page object at Index as an array of TPdfContentMark records. Each record carries the tag in Name, a Params array holding the property list, and a Handle. The name comes back without the leading slash, so a /P sequence reports P and an artifact reports Artifact. Marked-content sequences may nest, and a glyph run inside /Sect, then /P, then /Span reports three records on the same object, outermost first

ISO 32000-1 §14.6.2 allows the property list operand of BDC to take two forms: an inline dictionary, as in /P <</MCID 3>> BDC, or a name such as /OC /oc1 BDC that points into the /Properties subdictionary of the page resources. Optional content (§8.11.3.2) is written in the second form. PDFium resolves the named form while parsing the page, so in both cases Params lists the entries of the dictionary that applies to the sequence

TPdf.PageObjectMarkedContentID(Index) answers the question the structure tree cares about. It returns the MCID found in the object's marks, or -1 when none of them carries an integer /MCID. The header in the opening example returns -1, and that is correct: §14.8.2.2 keeps artifacts out of the logical structure, so an artifact has nothing to link to

Diagram of PDF marked content anatomy in Delphi: a /P tag with an MCID property list between BDC and EMC, the TPdfContentMark record fields, and the MCID link to the structure tree
A mark is a tag and typed property list between BDC and EMC, reported as a record whose MCID joins the structure tree
var
  Marks: TPdfContentMarks;
  M: TPdfContentMark;
  P: TPdfContentMarkParam;
  I: Integer;
begin
  Pdf.PageNumber := 1;                    // PageNumber is 1-based
  for I := 0 to Pdf.ObjectCount - 1 do    // page object indexes are 0-based
  begin
    Marks := Pdf.PageObjectMarks(I);
    if Length(Marks) = 0 then
      Continue;
    Memo1.Lines.Add(Format('object %d, MCID %d',
      [I, Pdf.PageObjectMarkedContentID(I)]));
    for M in Marks do                     // outermost mark first
    begin
      Memo1.Lines.Add('  mark ' + M.Name);
      for P in M.Params do
        case P.Kind of
          pmpInt:     Memo1.Lines.Add('    ' + P.Key + ' = ' + IntToStr(P.IntValue));
          pmpFloat:   Memo1.Lines.Add('    ' + P.Key + ' = ' + FloatToStr(P.FloatValue));
          pmpString:  Memo1.Lines.Add('    ' + P.Key + ' = ' + P.StringValue);
          pmpBlob:    Memo1.Lines.Add('    ' + P.Key + ' = ' +
                        IntToStr(Length(P.BlobValue)) + ' bytes');
          pmpUnknown: Memo1.Lines.Add('    ' + P.Key + ' = (no readable value)');
        end;
    end;
  end;
end;

What pmpUnknown can and cannot tell you

Each TPdfContentMarkParam has a Key, a Kind and one value field selected by that kind. The mapping is short enough to keep in your head:

  • A number with an integral value becomes pmpInt, with IntValue set and FloatValue filled with the same number, so /MCID 3 and a stray 3.0 both read as integers
  • A number with a fractional part becomes pmpFloat
  • PDF strings and PDF names both become pmpString; text strings that start with a UTF-16 byte order mark (FE FF or FF FE) are decoded into StringValue, so /ActualText arrives as readable text
  • Other value types come back as pmpBlob when PDFium hands over raw bytes for them, and stay pmpUnknown otherwise

pmpUnknown covers two cases that cannot be told apart. One is a value PDFium reports as FPDF_OBJECT_UNKNOWN and hands over no bytes for. The other is a key that is listed but cannot be read back: a key spelled with non-ASCII bytes in a damaged file can appear in the list and still come back as pmpUnknown. The record looks the same either way

Treat pmpUnknown as "no usable value" and report the mark rather than accept it. If your workflow depends on /Lang or /ActualText being present, look for the key with a kind you recognize; never conclude from an unknown kind that the property is absent

A Handle identifies a mark inside the currently loaded page and goes stale once the mark is removed, the page object is destroyed or the page is unloaded. Copy Name and Params into your own structures before you switch pages. For data that has to outlive the current page, such as a list view or a background audit, ReadPageObjectsSnapshot returns TPdfContentMarkSnapshot records that hold only the name and the parameters, with no handle at all. The same rule applies to page object handles in general, as covered in the notes on page object handles going stale after a transform

Adding, updating and removing marks

Four calls cover editing. AddPageObjectMark(Index, Name, Params) creates a new mark on the object, applies the parameters in order and returns the new mark's index on that object, which the other three calls take as MarkIndex. Names go in without a slash: 'P', 'Span'. To change one key, SetPageObjectMarkParam(Index, MarkIndex, Param) inserts or replaces it and leaves every other parameter on the mark alone. The removals are blunter. RemovePageObjectMarkParam(Index, MarkIndex, Key) drops a key; RemovePageObjectMark(Index, MarkIndex) drops the mark

AddPageObjectMark treats the mark and its parameters as one step. If a parameter is rejected (a pmpUnknown kind, an empty key, an empty blob), the mark it just created is removed again before the exception propagates, so the object never keeps a tag with half its property list

Every one of these calls rebuilds the page content stream before it returns. That matters because SaveAs writes the content stream as it stands and does not regenerate it from PDFium's object model. A mark that existed only on the in-memory object would leave the saved file without a single BDC operator. If you also edit page objects through raw PDFium imports on ObjectHandle, call UpdatePage yourself before saving

Diagram of adding a marked-content tag in Delphi: AddPageObjectMark rebuilds the page content stream before SaveAs, while an object-model-only change is silently dropped from the saved file
AddPageObjectMark rebuilds the content stream so SaveAs persists the tag, and a change that stops at the object model never reaches the file

Rebuilding the whole stream on every call is fine for a few edits and wasteful for a few hundred. Tagging an untagged report page by page is the second kind of job, and there the edits belong inside BeginPageObjectEditTransaction and CommitPageObjectEditTransaction. Inside the transaction each edit only marks the page as changed; the commit generates the content stream once, and RollbackPageObjectEditTransaction puts the page back as it was. A transaction is bound to one page, and setting PageNumber to another page while it is open raises. On a signed document the begin call refuses to start unless you pass AllowSignedDocument = True, since editing page content breaks the signature anyway

String parameters need one more decision. pmpString values are written as byte strings, which is exactly right for ASCII values such as /Lang (en-US). Replacement text with accents, ligatures or CJK characters belongs in a PDF text string encoded as UTF-16BE with a FE FF prefix (§7.9.2.2). Pass those bytes as pmpBlob: PDFium stores them as a string object, and the reader above decodes them back into StringValue as pmpString

// Your own helper, not part of the library: PDF text string as UTF-16BE with BOM
function Utf16TextString(const S: string): TBytes;
var
  I: Integer;
begin
  SetLength(Result, 2 + Length(S) * 2);
  Result[0] := $FE;
  Result[1] := $FF;
  for I := 1 to Length(S) do
  begin
    Result[I * 2] := Byte(Ord(S[I]) shr 8);
    Result[I * 2 + 1] := Byte(Ord(S[I]));
  end;
end;

procedure TagWord(Pdf: TPdf; ObjectIndex, Mcid: Integer; const Spoken: string);
var
  Params: TPdfContentMarkParams;
  Lang: TPdfContentMarkParam;
  MarkIndex: Integer;
begin
  Pdf.BeginPageObjectEditTransaction;
  try
    SetLength(Params, 2);
    Params[0].Key := 'MCID';
    Params[0].Kind := pmpInt;
    Params[0].IntValue := Mcid;
    Params[1].Key := 'ActualText';
    Params[1].Kind := pmpBlob;            // stored as a text string, reads back as pmpString
    Params[1].BlobValue := Utf16TextString(Spoken);
    MarkIndex := Pdf.AddPageObjectMark(ObjectIndex, 'Span', Params);

    Lang := Default(TPdfContentMarkParam);
    Lang.Key := 'Lang';
    Lang.Kind := pmpString;               // ASCII value, a plain byte string is fine
    Lang.StringValue := 'de-DE';
    Pdf.SetPageObjectMarkParam(ObjectIndex, MarkIndex, Lang);
  except
    Pdf.RollbackPageObjectEditTransaction;
    raise;
  end;
  Pdf.CommitPageObjectEditTransaction;    // one content stream rebuild for the whole batch
end;

The commit sits outside the try block on purpose. If content generation fails, CommitPageObjectEditTransaction restores the page itself before re-raising, and a second rollback call would only raise "No page-object edit transaction is active" over the real error

Why does one paragraph end up with three MCID 5 sequences?

Because each AddPageObjectMark call creates a new, independent mark dictionary, even when the values are identical. A paragraph laid out as three text objects, one per line, tagged with three calls that each pass /MCID 5, carries three separate marks. When PDFium regenerates the content stream it keeps consecutive objects inside one BDC/EMC pair only when they share the same mark; it compares mark identity, and equal values do not count. The saved page then holds /P <</MCID 5>> BDC … EMC three times in a row

ISO 32000-1 §14.7.4.2 describes the MCID as identifying a marked-content sequence uniquely within its content stream. Three sequences sharing one identifier do not meet that, and tools that resolve MCIDs through the page's parent tree entry are left to guess which of the three the structure element means

SharePageObjectMark(TargetIndex, SourceIndex, SourceMarkIndex) attaches the source mark's own property dictionary to the target object instead of building a copy. Add the mark once to the first line, share it to the others, and keep the objects consecutive in painting order; the regenerated stream then wraps all three lines in a single sequence

procedure TagParagraph(Pdf: TPdf; const LineObjects: array of Integer; Mcid: Integer);
// LineObjects: object indexes of the paragraph's lines, consecutive in painting order
var
  Params: TPdfContentMarkParams;
  MarkIndex, I: Integer;
begin
  SetLength(Params, 1);
  Params[0].Key := 'MCID';
  Params[0].Kind := pmpInt;
  Params[0].IntValue := Mcid;

  Pdf.BeginPageObjectEditTransaction;
  try
    MarkIndex := Pdf.AddPageObjectMark(LineObjects[0], 'P', Params);
    for I := 1 to High(LineObjects) do
      Pdf.SharePageObjectMark(LineObjects[I], LineObjects[0], MarkIndex);
  except
    Pdf.RollbackPageObjectEditTransaction;
    raise;
  end;
  Pdf.CommitPageObjectEditTransaction;
end;

Sharing has a second advantage when you copy an existing mark onto new content, for example extending an artifact over a redrawn footer rule. The parameter kinds PDFium can write are integer, float, string and byte blob, and there is no name kind. An /Artifact <</Type /Pagination>> read as pmpString and rebuilt through AddPageObjectMark comes out with (Pagination), a string, where §14.8.2.2 expects the name /Pagination. A shared mark is the original dictionary, so its names stay names. Whenever the source mark already exists on the page, share it; rebuild from Params only when you are writing a mark that never existed

Matching MCIDs against the structure tree

An MCID on a page object means something only when a structure element on the same page claims it. StructureElementsForPage[PageNumber] returns the page's elements as TPdfStructureElement records, and three fields carry MCIDs: MarkedContentID (-1 when the element has none), MarkedContentIDs for elements that own several sequences, and ChildMarkedContentIDs, with -1 in the slots of children that are elements rather than marked content. An audit has to look at all three, because a structure element's /K entry can mix child elements and marked-content references in one array

The audit below reports two findings. Text or images whose MCID no element claims, or which have no MCID at all, are content assistive technology will skip. An Artifact whose MCID is claimed contradicts §14.8.2.2 outright. The page objects come from ReadPageObjectsSnapshot, so the results hold no handles and survive a page switch

// Your own helper, not part of the library
function McidClaimed(const Elements: TPdfStructureElements; Mcid: Integer): Boolean;
var
  E: TPdfStructureElement;
  Id: Integer;
begin
  for E in Elements do
  begin
    if E.MarkedContentID = Mcid then
      Exit(True);
    for Id in E.MarkedContentIDs do
      if Id = Mcid then
        Exit(True);
    for Id in E.ChildMarkedContentIDs do
      if Id = Mcid then
        Exit(True);
  end;
  Result := False;
end;

procedure AuditPage(Pdf: TPdf; PageNo: Integer; Log: TStrings);
var
  Snap: TPdfPageObjectsSnapshot;
  Status: TPdfPageObjectSnapshotStatus;
  Elements: TPdfStructureElements;
  Obj: TPdfPageObjectSnapshot;
  IsArtifact: Boolean;
  J: Integer;
begin
  Snap := Pdf.ReadPageObjectsSnapshot(PageNo,
    TPdfPageObjectSnapshotOptions.Full, Status);
  if not Status.Completed then
    Log.Add(Format('page %d: snapshot hit a limit, results are partial', [PageNo]));
  Elements := Pdf.StructureElementsForPage[PageNo];
  for Obj in Snap.Objects do
  begin
    IsArtifact := False;
    for J := 0 to High(Obj.Marks) do
      if Obj.Marks[J].Name = 'Artifact' then
        IsArtifact := True;
    if IsArtifact then
    begin
      if (Obj.MarkedContentID >= 0) and McidClaimed(Elements, Obj.MarkedContentID) then
        Log.Add(Format('object %d: artifact claimed by the structure tree', [Obj.Index]));
    end
    else if Obj.ObjectType in [otText, otImage] then
      if (Obj.MarkedContentID < 0) or not McidClaimed(Elements, Obj.MarkedContentID) then
        Log.Add(Format('object %d: content with no structure owner', [Obj.Index]));
  end;
end;

Paths are left out on purpose. Decorative rules and table borders are often untagged, and flagging every one of them buries the text findings. Form XObjects deserve the opposite caution: an MCID inside a form belongs to that form's own /StructParents namespace, so MCID 0 on the page and MCID 0 inside a form are different sequences. The walkthrough on MCID namespaces in tagged Form XObjects covers reading those correctly

Marks are still only half of a tagged document. ISO 32000-1 §14.7 asks for a structure tree whose elements reference these MCIDs, a /MarkInfo dictionary in the catalog with /Marked true, and structure types that are either standard or mapped to standard ones through the role map. Pdf.IsTagged tells you whether the catalog declares the document tagged. A /P mark whose MCID no element points at produces content that claims to be tagged on a page the structure tree never mentions

PDFium Component diagram showing that BDC marks alone do not make a tagged PDF, which also needs a structure tree referencing MCIDs, a MarkInfo declaration and standard role names
Marks support auditing and orphan repair while a conforming tagged document also needs a structure tree, MarkInfo and standard role names

For the structure-tree side of that work, the walkthrough of PDF/UA structure tree validation picks up where the MCID audit stops. The reading experience the tags exist for is the subject of the notes on building an accessible PDF reader in Delphi

The calls used here (mark reading, editing transactions, shared marks, page object snapshots and structure elements) are part of the same VCL API that PDFium Component gives Delphi, C++Builder and Lazarus projects over the PDFium engine. The PDFium Component product page covers the rest of the accessibility and page-editing API