Technisch artikel

PDF marked content lezen en schrijven in Delphi

In een tagged PDF zit de doorlopende koptekst bovenaan elke pagina meestal binnen /Artifact <</Type /Pagination>> BDC … EMC, terwijl de eerste alinea eronder binnen /P <</MCID 0>> BDC … EMC zit. Beide zijn marked content in de zin van ISO 32000-1 §14.6. Alleen de alinea is bereikbaar vanaf de structuurboom, want alleen de alinea draagt een marked-content-identificator, en dat ene integer is wat geschilderde glyphs koppelt aan een structuurelement als /H1 of /P. Code die tagging auditeert of repareert moet daarom drie dingen van elk pagina-object lezen (de tag, zijn propertylijst en de MCID) en het getal dan toetsen aan de MCIDs die de structuurelementen op die pagina werkelijk claimen

BDC-marks en propertylijsten lezen vanuit Delphi

In PDFium Component geeft TPdf.PageObjectMarks(Index) elke mark terug die het pagina-object op Index omhult, als een array van TPdfContentMark-records. Elk record draagt de tag in Name, een Params-array met de propertylijst, en een Handle. De naam komt terug zonder de voorloopslash: een /P-sequentie meldt P en een artifact meldt Artifact. Marked-content-sequenties kunnen nesten, en een glyph-run binnen /Sect, daarna /P, daarna /Span meldt drie records op hetzelfde object, buitenste eerst

ISO 32000-1 §14.6.2 staat toe dat de propertylijst-operand van BDC twee vormen heeft: een inline dictionary, als in /P <</MCID 3>> BDC, of een naam als /OC /oc1 BDC die wijst naar het /Properties-subdictionary van de paginabronnen. Optional content (§8.11.3.2) wordt in de tweede vorm geschreven. PDFium lost de benoemde vorm op tijdens het parsen van de pagina, dus in beide gevallen somt Params de entries op van het dictionary dat op de sequentie van toepassing is

TPdf.PageObjectMarkedContentID(Index) beantwoordt de vraag waar de structuurboom om geeft. Het geeft de MCID terug die in de marks van het object zit, of -1 wanneer geen ervan een integer /MCID draagt. De koptekst uit het openingsvoorbeeld geeft -1 terug, en dat klopt: §14.8.2.2 houdt artifacts buiten de logische structuur, dus een artifact heeft niets om naar te verwijzen

Diagram van de anatomie van PDF marked content in Delphi: een /P-tag met een MCID-propertylijst tussen BDC en EMC, de TPdfContentMark-recordvelden, en de MCID-link naar de structuurboom
Een mark is een tag en een getypeerde propertylijst tussen BDC en EMC, gemeld als een record waarvan de MCID aansluit op de structuurboom
var
  Marks: TPdfContentMarks;
  M: TPdfContentMark;
  P: TPdfContentMarkParam;
  I: Integer;
begin
  Pdf.PageNumber := 1;                    // PageNumber telt vanaf 1
  for I := 0 to Pdf.ObjectCount - 1 do    // pagina-objectindexen zijn 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                     // buitenste mark eerst
    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;

Wat pmpUnknown wel en niet vertelt

Elke TPdfContentMarkParam heeft een Key, een Kind en één waardeveld dat door dat kind wordt gekozen. De mapping is kort genoeg om uit je hoofd te kennen:

  • Een getal met een integrale waarde wordt pmpInt, met IntValue gevuld en FloatValue met hetzelfde getal, dus /MCID 3 en een verdwaalde 3.0 lezen beide als integers
  • Een getal met een breukdeel wordt pmpFloat
  • PDF-strings en PDF-namen worden beide pmpString; tekststrings die met een UTF-16 byte order mark beginnen (FE FF of FF FE) worden gedecodeerd naar StringValue, dus /ActualText arriveert als leesbare tekst
  • Andere waardetypes komen terug als pmpBlob wanneer PDFium er rauwe bytes voor overhandigt, en blijven anders pmpUnknown

pmpUnknown dekt twee gevallen die niet uit elkaar te houden zijn. De ene is een waarde die PDFium als FPDF_OBJECT_UNKNOWN meldt en waarvoor het geen bytes overhandigt. De andere is een sleutel die wel in de lijst staat maar niet terug te lezen is: een sleutel gespeld met niet-ASCII-bytes in een beschadigd bestand kan in de lijst verschijnen en toch als pmpUnknown terugkomen. Het record ziet er beide keren hetzelfde uit

Behandel pmpUnknown als "geen bruikbare waarde" en rapporteer de mark in plaats van hem klakkeloos te accepteren. Als je workflow afhangt van de aanwezigheid van /Lang of /ActualText, zoek dan naar de sleutel met een kind dat je herkent; concludeer nooit uit een onbekend kind dat de property ontbreekt

Een Handle identificeert een mark binnen de momenteel geladen pagina en raakt stale zodra de mark wordt verwijderd, het pagina-object wordt vernietigd of de pagina wordt gelost. Kopieer Name en Params naar je eigen structuren voordat je van pagina wisselt. Voor data die de huidige pagina moet overleven, zoals een lijstweergave of een audit op de achtergrond, geeft ReadPageObjectsSnapshot TPdfContentMarkSnapshot-records terug die alleen de naam en de parameters bevatten, zonder enige handle. Dezelfde regel geldt voor pagina-object-handles in het algemeen, zoals behandeld in de notities over pagina-object-handles die stale raken na een transformatie

Marks toevoegen, bijwerken en verwijderen

Vier calls dekken het bewerken af. AddPageObjectMark(Index, Name, Params) maakt een nieuwe mark op het object, past de parameters in volgorde toe en geeft de index van de nieuwe mark op dat object terug; de andere drie calls nemen die als MarkIndex. Namen gaan zonder slash naar binnen: 'P', 'Span'. Om één sleutel te wijzigen, voegt SetPageObjectMarkParam(Index, MarkIndex, Param) die in of vervangt die en laat elke overige parameter op de mark met rust. De twee verwijdercalls zijn directer. RemovePageObjectMarkParam(Index, MarkIndex, Key) gooit een sleutel weg; RemovePageObjectMark(Index, MarkIndex) gooit de mark weg

AddPageObjectMark behandelt de mark en zijn parameters als één stap. Wordt een parameter afgewezen (een pmpUnknown-kind, een lege sleutel, een lege blob), dan wordt de mark die hij net maakte weer verwijderd voordat de exception zich verder verspreidt, dus het object houdt nooit een tag over met de halve propertylijst

Elk van deze calls herbouwt de pagina-contentstream voordat hij terugkeert. Dat is belangrijk omdat SaveAs de contentstream wegschrijft zoals hij is en hem niet opnieuw genereert vanuit PDFiums objectmodel. Een mark die alleen op het in-memory object bestond, laat het opgeslagen bestand achter zonder een enkele BDC-operator. Bewerk je pagina-objecten ook rechtstreeks via de rauwe PDFium-imports op ObjectHandle, roep dan zelf UpdatePage aan voordat je opslaat

Diagram van het toevoegen van een marked-content-tag in Delphi: AddPageObjectMark herbouwt de paginacontentstream vóór SaveAs, terwijl een wijziging die stopt bij het objectmodel stilzwijgend uit het opgeslagen bestand blijft
AddPageObjectMark herbouwt de contentstream zodat SaveAs de tag bewaart, en een wijziging die bij het objectmodel stopt bereikt het bestand nooit

De hele stream bij elke call herbouwen is prima voor een paar bewerkingen en verspillend voor een paar honderd. Een ongetagd rapport pagina voor pagina taggen is het tweede soort klus, en daar horen de bewerkingen binnen BeginPageObjectEditTransaction en CommitPageObjectEditTransaction. Binnen de transactie markeert elke bewerking de pagina slechts als gewijzigd; de commit genereert de contentstream één keer, en RollbackPageObjectEditTransaction zet de pagina terug zoals hij was. Een transactie is gebonden aan één pagina; zet je PageNumber op een andere pagina terwijl hij open staat, dan krijg je een raise. Op een ondertekend document weigert de begin-aanroep te starten tenzij je AllowSignedDocument = True doorgeeft, want het bewerken van paginacontent breekt toch al de handtekening

String-parameters vragen om één beslissing extra. pmpString-waarden worden als byte-strings weggeschreven, wat precies goed is voor ASCII-waarden als /Lang (en-US). Vervangende tekst met accenten, ligaturen of CJK-tekens hoort in een PDF-textstring, gecodeerd als UTF-16BE met een FE FF-prefix (§7.9.2.2). Geef die bytes door als pmpBlob: PDFium bewaart ze als string-object, en de reader hierboven decodeert ze terug naar StringValue als pmpString

// Eigen hulpfunctie, geen onderdeel van de bibliotheek: PDF-textstring als UTF-16BE met 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;            // bewaard als text string, leest terug als pmpString
    Params[1].BlobValue := Utf16TextString(Spoken);
    MarkIndex := Pdf.AddPageObjectMark(ObjectIndex, 'Span', Params);

    Lang := Default(TPdfContentMarkParam);
    Lang.Key := 'Lang';
    Lang.Kind := pmpString;               // ASCII-waarde, een gewone byte-string is prima
    Lang.StringValue := 'de-DE';
    Pdf.SetPageObjectMarkParam(ObjectIndex, MarkIndex, Lang);
  except
    Pdf.RollbackPageObjectEditTransaction;
    raise;
  end;
  Pdf.CommitPageObjectEditTransaction;    // één herbouw van de contentstream voor de hele batch
end;

De commit staat met opzet buiten het try-blok. Als contentgeneratie faalt, herstelt CommitPageObjectEditTransaction zelf de pagina voordat hij de exception opnieuw gooit, en een tweede rollback-aanroep zou alleen maar "No page-object edit transaction is active" over de echte fout heen gooien

Waarom krijgt één alinea drie MCID 5-sequenties?

Omdat elke AddPageObjectMark-call een nieuw, onafhankelijk mark-dictionary maakt, ook als de waarden identiek zijn. Een alinea die als drie tekstobjecten is gezet, één per regel, en met drie calls is getagd die elk /MCID 5 doorgeven, draagt drie aparte marks. Bij het hergenereren van de contentstream houdt PDFium opeenvolgende objecten alleen binnen één BDC/EMC-paar wanneer ze dezelfde mark delen; hij vergelijkt mark-identiteit, en gelijke waarden tellen niet mee. De opgeslagen pagina bevat dan /P <</MCID 5>> BDC … EMC drie keer op een rij

ISO 32000-1 §14.7.4.2 beschrijft de MCID als de identificator die een marked-content-sequentie uniek maakt binnen zijn contentstream. Drie sequenties die één identificator delen voldoen daar niet aan, en tools die MCIDs opzoeken via de parent-tree-entry van de pagina blijven raden welke van de drie het structuurelement bedoelt

SharePageObjectMark(TargetIndex, SourceIndex, SourceMarkIndex) koppelt de eigen property-dictionary van de bronmark aan het doelobject in plaats van een kopie te bouwen. Voeg de mark één keer toe aan de eerste regel, deel hem met de andere, en houd de objecten opeenvolgend in tekenvolgorde; de geregenereerde stream wikkelt dan alle drie de regels in één sequentie

procedure TagParagraph(Pdf: TPdf; const LineObjects: array of Integer; Mcid: Integer);
// LineObjects: objectindexen van de regels van de alinea, opeenvolgend in tekenvolgorde
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;

Delen heeft nog een voordeel wanneer je een bestaande mark kopieert naar nieuwe content, bijvoorbeeld een artifact uitbreiden over een opnieuw getrokken voettekstlijn. De parametertypes die PDFium kan schrijven zijn integer, float, string en byte-blob; een nametype bestaat niet. Een /Artifact <</Type /Pagination>> gelezen als pmpString en herbouwd via AddPageObjectMark komt eruit met (Pagination), een string, waar §14.8.2.2 de naam /Pagination verwacht. Een gedeelde mark is het originele dictionary, dus zijn namen blijven namen. Bestaat de bronmark al op de pagina, deel hem dan; bouw alleen vanuit Params op als je een mark schrijft die nog nooit heeft bestaan

MCIDs matchen tegen de structuurboom

Een MCID op een pagina-object zegt pas iets wanneer een structuurelement op dezelfde pagina hem claimt. StructureElementsForPage[PageNumber] geeft de elementen van de pagina terug als TPdfStructureElement-records, en drie velden dragen MCIDs: MarkedContentID (-1 wanneer het element er geen heeft), MarkedContentIDs voor elementen die meerdere sequenties bezitten, en ChildMarkedContentIDs, met -1 op de plekken van kinderen die zelf elementen zijn in plaats van marked content. Een audit moet naar alle drie kijken, want de /K-entry van een structuurelement kan kind-elementen en marked-content-verwijzingen door elkaar in één array zetten

De audit hieronder meldt twee bevindingen. Tekst of afbeeldingen waarvan geen element de MCID claimt, of die helemaal geen MCID hebben, zijn content die assistieve technologie overslaat. Een Artifact waarvan de MCID wél geclaimd wordt spreekt §14.8.2.2 ronduit tegen. De pagina-objecten komen uit ReadPageObjectsSnapshot, dus de resultaten bevatten geen handles en overleven een paginawissel

// Eigen hulpfunctie, geen onderdeel van de bibliotheek
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;

Paden zijn met opzet weggelaten. Decoratieve lijnen en tabelranden zijn vaak ongetagd, en die allemaal aanmerken begraaft de tekstbevindingen. Form XObjects verdienen juist extra waakzaamheid: een MCID binnen een form hoort bij de eigen /StructParents-naamruimte van die form, dus MCID 0 op de pagina en MCID 0 binnen een form zijn verschillende sequenties. De doorloop over MCID-naamruimtes in getagde Form XObjects behandelt hoe je die correct leest

Marks zijn nog steeds maar de helft van een getagd document. ISO 32000-1 §14.7 vraagt om een structuurboom waarvan de elementen naar deze MCIDs verwijzen, een /MarkInfo-dictionary in de catalog met /Marked true, en structuurtypen die ofwel standaard zijn ofwel via de role map aan standaardtypen zijn gekoppeld. Pdf.IsTagged vertelt of de catalog het document als getagd declareert. Een /P-mark waarvan geen element de MCID aanwijst, levert content op die beweert getagd te zijn op een pagina waar de structuurboom het nooit over heeft

PDFium Component-diagram dat toont dat BDC-marks alleen geen getagde PDF maken, die ook een structuurboom nodig heeft die naar MCIDs verwijst, een MarkInfo-verklaring en standaard rolnamen
Marks ondersteunen audit en wees-reparatie, terwijl een conformerend getagd document ook een structuurboom, MarkInfo en standaard rolnamen nodig heeft

Voor de structuurboomkant van dat werk pikt de doorloop van PDF/UA-structuurboomvalidatie in waar de MCID-audit stopt. De leeservaring waarvoor de tags bestaan is het onderwerp van de notities over het bouwen van een toegankelijke PDF-reader in Delphi

De calls die hier gebruikt worden (marks lezen, bewerkingstransacties, gedeelde marks, pagina-object-snapshots en structuurelementen) maken deel uit van dezelfde VCL-API waarmee PDFium Component Delphi-, C++Builder- en Lazarus-projecten de PDFium-engine bedient. De PDFium Component-productpagina behandelt de rest van de toegankelijkheids- en pagina-bewerkings-API