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
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 gesetztemIntValueund einemFloatValue, der dieselbe Zahl enthält,/MCID 3und eine verirrte3.0lesen 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 FFoderFF FE), werden inStringValuedekodiert,/ActualTextkommt also als lesbarer Text an - Andere Werttypen kommen als
pmpBlobzurück, wenn PDFium rohe Bytes dafür herausrückt, und bleiben sonstpmpUnknown
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
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
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