Technischer Artikel

Markierten PDF-Inhalt lesen und schreiben in Delphi

In einem getaggten PDF steckt der laufende Kopf am oberen Rand jeder Seite meist innerhalb von /Artifact <</Type /Pagination>> BDC … EMC, während der erste Absatz darunter innerhalb von /P <</MCID 0>> BDC … EMC sitzt. Beides ist markierter Inhalt im Sinne von ISO 32000-1 §14.6. Nur der Absatz ist vom Strukturbaum aus erreichbar, denn nur er trägt eine Marked-Content-ID, und genau diese ganze Zahl verbindet gemalte Glyphen mit einem Strukturelement wie /H1 oder /P. Code, der Tagging auditiert oder repariert, muss daher drei Dinge von jedem Seitenobjekt lesen (das Tag, seine Eigenschaftsliste und die MCID) und die Zahl dann gegen die MCIDs prüfen, die die Strukturelemente dieser Seite tatsächlich beanspruchen

BDC-Markierungen und Eigenschaftslisten aus Delphi lesen

In PDFium Component liefert TPdf.PageObjectMarks(Index) jede Markierung, die das Seitenobjekt an Index umschließt, als Array von TPdfContentMark-Records. Jeder Record trägt das Tag in Name, ein Params-Array mit der Eigenschaftsliste und ein Handle. Der Name kommt ohne führenden Schrägstrich zurück: Eine /P-Sequenz meldet P, ein Artifact meldet Artifact. Marked-Content-Sequenzen dürfen schachteln — eine Glyphengruppe innerhalb von /Sect, dann /P, dann /Span meldet drei Records für dasselbe Objekt, äußerste zuerst

ISO 32000-1 §14.6.2 erlaubt für den Eigenschaftslisten-Operanden von BDC zwei Formen: ein Inline-Dictionary wie in /P <</MCID 3>> BDC oder einen Namen wie /OC /oc1 BDC, der ins /Properties-Unterdictionary der Seitenressourcen zeigt. Optionale Inhalte (§8.11.3.2) werden in der zweiten Form geschrieben. PDFium löst die Namensform beim Parsen der Seite auf, daher listet Params in beiden Fällen die Einträge des Dictionarys, der für die Sequenz gilt

TPdf.PageObjectMarkedContentID(Index) beantwortet die Frage, die den Strukturbaum interessiert. Es liefert die MCID aus den Markierungen des Objekts oder -1, wenn keine davon ein ganzzahliges /MCID trägt. Der Kopf aus dem Eingangsbeispiel liefert -1, und das ist korrekt: §14.8.2.2 hält Artefakte außerhalb der logischen Struktur, ein Artifact hat also nichts, woran es sich verknüpfen ließe

Diagramm der PDF-Marked-Content-Anatomie in Delphi: ein /P-Tag mit einer MCID-Eigenschaftsliste zwischen BDC und EMC, die TPdfContentMark-Record-Felder und die MCID-Verknüpfung zum Strukturbaum
Eine Markierung ist ein Tag und eine typisierte Eigenschaftsliste zwischen BDC und EMC, gemeldet als Record, dessen MCID dem Strukturbaum beitritt
var
  Marks: TPdfContentMarks;
  M: TPdfContentMark;
  P: TPdfContentMarkParam;
  I: Integer;
begin
  Pdf.PageNumber := 1;                    // PageNumber ist 1-basiert
  for I := 0 to Pdf.ObjectCount - 1 do    // Seitenobjektindizes sind 0-basiert
  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                     // äußerste Markierung zuerst
    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;

Was pmpUnknown verrät und was nicht

Jeder TPdfContentMarkParam hat einen Key, eine Kind und genau ein Wertfeld, das diese Kind auswählt. Die Zuordnung ist kurz genug, um sie im Kopf zu behalten:

  • Eine Zahl mit ganzem Wert wird zu pmpInt, mit gesetztem IntValue und einem FloatValue, der dieselbe Zahl enthält, /MCID 3 und eine verirrte 3.0 lesen sich also beide als Integer
  • Eine Zahl mit Nachkommateil wird zu pmpFloat
  • PDF-Strings und PDF-Namen werden beide zu pmpString; Text-Strings, die mit einer UTF-16-Byte-Reihenfolgemarke beginnen (FE FF oder FF FE), werden in StringValue dekodiert, /ActualText kommt also als lesbarer Text an
  • Andere Werttypen kommen als pmpBlob zurück, wenn PDFium rohe Bytes dafür herausrückt, und bleiben sonst pmpUnknown

pmpUnknown deckt zwei Fälle ab, die sich nicht unterscheiden lassen. Der eine ist ein Wert, den PDFium als FPDF_OBJECT_UNKNOWN meldet und zu dem es keine Bytes herausrückt. Der andere ist ein Schlüssel, der zwar gelistet, aber nicht zurückgelesen werden kann: Ein Schlüssel mit Nicht-ASCII-Bytes in einer beschädigten Datei kann in der Liste auftauchen und trotzdem als pmpUnknown zurückkommen. Der Record sieht in beiden Fällen gleich aus

pmpUnknown als „kein verwendbarer Wert" behandeln und die Markierung reporten, statt sie stillschweigend zu akzeptieren. Hängt der Workflow davon ab, dass /Lang oder /ActualText vorhanden sind, sucht man den Schlüssel mit einer Kind, die man erkennt; aus einer unbekannten Kind sollte man niemals schließen, dass die Eigenschaft fehlt

Ein Handle identifiziert eine Markierung innerhalb der aktuell geladenen Seite und veraltet, sobald die Markierung entfernt, das Seitenobjekt zerstört oder die Seite entladen wird. Name und Params vor dem Seitenwechsel in eigene Strukturen kopieren. Für Daten, die die aktuelle Seite überleben müssen, etwa eine Listenansicht oder ein Hintergrund-Audit, liefert ReadPageObjectsSnapshot TPdfContentMarkSnapshot-Records, die nur Namen und Parameter halten, ganz ohne Handle. Dieselbe Regel gilt für Seitenobjekt-Handles generell, wie die Hinweise zu Seitenobjekt-Handles, die nach einer Transformation veralten erklären

Markierungen hinzufügen, aktualisieren und entfernen

Vier Aufrufe decken das Bearbeiten ab. AddPageObjectMark(Index, Name, Params) erzeugt eine neue Markierung am Objekt, wendet die Parameter der Reihe nach an und liefert den Index der neuen Markierung auf diesem Objekt zurück, den die anderen drei Aufrufe als MarkIndex entgegennehmen. Namen kommen ohne Schrägstrich hinein: 'P', 'Span'. Um einen einzelnen Schlüssel zu ändern, fügt SetPageObjectMarkParam(Index, MarkIndex, Param) ihn ein oder ersetzt ihn und lässt alle übrigen Parameter der Markierung unangetastet. Die Entfernungsroutinen sind unverblümter: RemovePageObjectMarkParam(Index, MarkIndex, Key) wirft einen Schlüssel raus, RemovePageObjectMark(Index, MarkIndex) wirft die ganze Markierung weg

AddPageObjectMark behandelt die Markierung und ihre Parameter als einen Schritt. Wird ein Parameter abgelehnt (eine pmpUnknown-Kind, ein leerer Schlüssel, ein leerer Blob), wird die gerade erzeugte Markierung wieder entfernt, bevor die Exception nach oben läuft, sodass das Objekt nie ein Tag mit halber Eigenschaftsliste behält

Jeder dieser Aufrufe baut den Seiten-Content-Stream neu auf, bevor er zurückkehrt. Das ist wichtig, weil SaveAs den Content-Stream so schreibt, wie er gerade dasteht, und ihn nicht aus PDFiums Objektmodell neu erzeugt. Eine Markierung, die nur am Objekt im Speicher existierte, würde die gespeicherte Datei ohne einen einzigen BDC-Operator lassen. Wer Seitenobjekte zusätzlich über rohe PDFium-Imports am ObjectHandle bearbeitet, ruft vor dem Speichern selbst UpdatePage auf

Diagramm des Hinzufügens eines Marked-Content-Tags in Delphi: AddPageObjectMark baut den Seitencontent-Stream vor SaveAs neu auf, während eine nur im Objektmodell gemachte Änderung still aus der gespeicherten Datei fällt
AddPageObjectMark baut den Content-Stream neu, sodass SaveAs den Tag persistiert, und eine Änderung, die beim Objektmodell stoppt, erreicht die Datei nie

Den kompletten Stream bei jedem Aufruf neu aufzubauen ist bei wenigen Änderungen in Ordnung und bei ein paar hundert Verschwendung. Ein ungetaggten Bericht Seite für Seite zu taggen ist die zweite Art von Job, und dort gehören die Änderungen in BeginPageObjectEditTransaction und CommitPageObjectEditTransaction. Innerhalb der Transaktion markiert jede Änderung die Seite nur als geändert; der Commit erzeugt den Content-Stream genau einmal, und RollbackPageObjectEditTransaction setzt die Seite zurück, wie sie war. Eine Transaktion ist an eine Seite gebunden, und PageNumber auf eine andere Seite zu setzen, während sie offen ist, wirft eine Exception. Bei signierten Dokumenten verweigert der Begin-Aufruf den Start, solange man nicht AllowSignedDocument = True übergibt — Seiteninhalt zu bearbeiten bricht die Signatur ohnehin

String-Parameter brauchen eine Entscheidung mehr. pmpString-Werte werden als Byte-Strings geschrieben, was für ASCII-Werte wie /Lang (en-US) genau richtig ist. Ersetzungstext mit Akzenten, Ligaturen oder CJK-Zeichen gehört in einen PDF-Text-String, kodiert als UTF-16BE mit FE FF-Präfix (§7.9.2.2). Diese Bytes übergibt man als pmpBlob: PDFium legt sie als String-Objekt ab, und der Reader oben dekodiert sie zurück in StringValue als pmpString

// Eigener Helfer, nicht Teil der Bibliothek: PDF-Text-String als UTF-16BE mit 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;            // als Text-String gespeichert, liest sich zurück als pmpString
    Params[1].BlobValue := Utf16TextString(Spoken);
    MarkIndex := Pdf.AddPageObjectMark(ObjectIndex, 'Span', Params);

    Lang := Default(TPdfContentMarkParam);
    Lang.Key := 'Lang';
    Lang.Kind := pmpString;               // ASCII-Wert, ein simpler Byte-String genügt
    Lang.StringValue := 'de-DE';
    Pdf.SetPageObjectMarkParam(ObjectIndex, MarkIndex, Lang);
  except
    Pdf.RollbackPageObjectEditTransaction;
    raise;
  end;
  Pdf.CommitPageObjectEditTransaction;    // ein einziger Content-Stream-Neuaufbau für den ganzen Batch
end;

Der Commit sitzt absichtlich außerhalb des try-Blocks. Schlägt die Inhaltserzeugung fehl, stellt CommitPageObjectEditTransaction die Seite selbst wieder her, bevor es die Exception erneut auslöst, und ein zweiter Rollback-Aufruf würde nur „No page-object edit transaction is active" über den eigentlichen Fehler stülpen

Warum endet ein Absatz mit drei MCID-5-Sequenzen?

Weil jeder AddPageObjectMark-Aufruf ein neues, unabhängiges Markierungs-Dictionary anlegt, selbst wenn die Werte identisch sind. Ein Absatz, der als drei Textobjekte layoutet ist, eines pro Zeile, und mit drei Aufrufen getaggt wird, die jeweils /MCID 5 übergeben, trägt drei getrennte Markierungen. Wenn PDFium den Content-Stream neu erzeugt, hält es aufeinanderfolgende Objekte nur dann in einem BDC/EMC-Paar zusammen, wenn sie dieselbe Markierung teilen; verglichen wird die Identität der Markierung, gleiche Werte zählen nicht. Die gespeicherte Seite enthält dann /P <</MCID 5>> BDC … EMC dreimal hintereinander

ISO 32000-1 §14.7.4.2 beschreibt die MCID als eindeutige Kennzeichnung einer Marked-Content-Sequenz innerhalb ihres Content-Streams. Drei Sequenzen mit einem gemeinsamen Bezeichner erfüllen das nicht, und Werkzeuge, die MCIDs über den Parent-Tree-Eintrag der Seite auflösen, dürfen raten, welche der drei das Strukturelement meint

SharePageObjectMark(TargetIndex, SourceIndex, SourceMarkIndex) hängt das eigene Eigenschafts-Dictionary der Quellmarkierung an das Zielobjekt, statt eine Kopie zu bauen. Die Markierung einmal an die erste Zeile hängen, an die anderen teilen und die Objekte in Paint-Reihenfolge aufeinanderfolgend halten; der neu erzeugte Stream wickelt dann alle drei Zeilen in eine einzige Sequenz

procedure TagParagraph(Pdf: TPdf; const LineObjects: array of Integer; Mcid: Integer);
// LineObjects: Objektindizes der Absatzzeilen, aufeinanderfolgend in Paint-Reihenfolge
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;

Teilen hat einen zweiten Vorteil, wenn man eine bestehende Markierung auf neuen Inhalt kopiert, etwa ein Artifact über eine neu gezeichnete Fußlinie ausdehnt. Die Parameter-Kinds, die PDFium schreiben kann, sind Integer, Float, String und Byte-Blob, einen Namen-Kind gibt es nicht. Ein als pmpString gelesenes /Artifact <</Type /Pagination>>, das über AddPageObjectMark neu aufgebaut wird, landet als (Pagination), ein String, wo §14.8.2.2 den Namen /Pagination erwartet. Eine geteilte Markierung ist das Original-Dictionary, ihre Namen bleiben also Namen. Solange die Quellmarkierung auf der Seite schon existiert, teilen; aus Params nur dann neu aufbauen, wenn man eine Markierung schreibt, die es noch nie gab

MCIDs gegen den Strukturbaum abgleichen

Eine MCID an einem Seitenobjekt bedeutet nur etwas, wenn ein Strukturelement derselben Seite sie beansprucht. StructureElementsForPage[PageNumber] liefert die Elemente der Seite als TPdfStructureElement-Records, und drei Felder tragen MCIDs: MarkedContentID (-1, wenn das Element keine hat), MarkedContentIDs für Elemente mit mehreren Sequenzen und ChildMarkedContentIDs mit -1 in den Slots von Kindern, die Elemente statt markiertem Inhalt sind. Ein Audit muss alle drei ansehen, denn der /K-Eintrag eines Strukturelements kann Kinderelemente und Marked-Content-Referenzen in einem Array mischen

Das Audit unten meldet zwei Befunde. Text oder Bilder, deren MCID kein Element beansprucht oder die gar keine MCID haben, sind Inhalt, den assistive Technik überspringen wird. Ein Artifact, dessen MCID beansprucht wird, widerspricht §14.8.2.2 glatt. Die Seitenobjekte kommen aus ReadPageObjectsSnapshot, die Ergebnisse halten also keine Handles und überleben einen Seitenwechsel

// Eigener Helfer, nicht Teil der Bibliothek
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;

Linien bleiben absichtlich außen vor. Dekorative Trennlinien und Tabellenrahmen sind oft ungetaggt, und jede einzelne zu markieren begräbt die Text-Befunde. Form XObjects verdienen die umgekehrte Vorsicht: Eine MCID innerhalb eines Formulars gehört zum eigenen /StructParents-Namespace dieses Formulars, MCID 0 auf der Seite und MCID 0 im Formular sind also verschiedene Sequenzen. Die Anleitung zu MCID-Namespaces in getaggten Form XObjects erklärt, wie man die korrekt liest

Markierungen sind weiterhin nur die Hälfte eines getaggten Dokuments. ISO 32000-1 §14.7 verlangt einen Strukturbaum, dessen Elemente diese MCIDs referenzieren, ein /MarkInfo-Dictionary im Katalog mit /Marked true und Strukturtypen, die entweder Standard sind oder über die Role Map auf Standardtypen gemappt werden. Pdf.IsTagged verrät, ob der Katalog das Dokument als getaggt deklariert. Eine /P-Markierung, auf deren MCID kein Element zeigt, erzeugt Inhalt, der auf einer Seite behauptet, getaggt zu sein, die der Strukturbaum nie erwähnt

PDFium-Component-Diagramm: BDC-Marken allein machen keine getaggte PDF; nötig sind zusätzlich ein Strukturbaum mit MCID-Referenzen, eine MarkInfo-Deklaration und Standardrollennamen
Markierungen stützen Audits und Waisen-Reparatur, während ein konformes getaggtes Dokument zusätzlich einen Strukturbaum, MarkInfo und Standardrollennamen braucht

Für die Strukturbaum-Seite dieser Arbeit macht die Anleitung zur PDF/UA-Strukturbaum-Validierung dort weiter, wo das MCID-Audit aufhört. Das Leseerlebnis, für das die Tags da sind, ist Thema der Hinweise zum Bau eines barrierefreien PDF-Readers in Delphi

Die hier verwendeten Aufrufe (Markierungen lesen, Editier-Transaktionen, geteilte Markierungen, Seitenobjekt-Snapshots und Strukturelemente) sind Teil derselben VCL-API, die PDFium Component Delphi-, C++Builder- und Lazarus-Projekten über die PDFium-Engine gibt. Die PDFium-Component-Produktseite deckt den Rest der Barrierefreiheits- und Seitenbearbeitungs-API ab