Artykuł techniczny

Odczyt i zapis oznaczonej treści PDF w Delphi

W otagowanym PDF bieżący nagłówek na górze każdej strony siedzi zwykle wewnątrz /Artifact <</Type /Pagination>> BDC … EMC, a pierwszy pod nim akapit — wewnątrz /P <</MCID 0>> BDC … EMC. Oba są oznaczoną treścią w rozumieniu ISO 32000-1 §14.6, ale z drzewa struktury osiągalny jest tylko akapit, bo tylko on niesie identyfikator oznaczonej treści, i ta jedna liczba całkowita spina namalowane glify z elementem struktury takim jak /H1 czy /P. Kod, który audytuje albo naprawia tagowanie, musi więc odczytać z każdego obiektu strony trzy rzeczy (znacznik, jego listę właściwości i MCID), a potem sprawdzić tę liczbę wobec MCID, które elementy struktury na tej stronie faktycznie zgłaszają

Odczyt znaczników BDC i list właściwości z Delphi

W PDFium Component metoda TPdf.PageObjectMarks(Index) zwraca każdy znacznik otaczający obiekt strony o indeksie Index jako tablicę rekordów TPdfContentMark. Każdy rekord niesie znacznik w Name, tablicę Params z listą właściwości oraz Handle. Nazwa przychodzi bez wiodącego ukośnika: sekwencja /P raportuje P, a artefakt raportuje Artifact. Sekwencje oznaczonej treści mogą się zagnieżdżać i ciąg glifów wewnątrz /Sect, potem /P, potem /Span raportuje na tym samym obiekcie trzy rekordy, od zewnętrznego

ISO 32000-1 §14.6.2 pozwala, by operand listy właściwości operatora BDC miał dwie postaci: słownik inline, jak w /P <</MCID 3>> BDC, albo nazwę taką jak /OC /oc1 BDC, która wskazuje w podsłownik /Properties zasobów strony. Zawartość opcjonalna (§8.11.3.2) zapisywana jest w tej drugiej postaci. PDFium rozwiązuje postać nazwaną w trakcie parsowania strony, więc w obu przypadkach Params wylicza wpisy słownika, który obowiązuje daną sekwencję

TPdf.PageObjectMarkedContentID(Index) odpowiada na pytanie, które naprawdę obchodzi drzewo struktury. Zwraca MCID znaleziony w znacznikach obiektu albo -1, gdy żaden z nich nie niesie całkowitoliczbowego /MCID. Nagłówek z przykładu otwierającego zwraca -1 i to jest poprawne: §14.8.2.2 trzyma artefakty poza strukturą logiczną, więc artefakt nie ma się z czym łączyć

Diagram anatomii oznaczonej treści PDF w Delphi: znacznik /P z listą właściwości MCID między BDC a EMC, pola rekordu TPdfContentMark oraz powiązanie MCID z drzewem struktury
Znacznik to tag i typowana lista właściwości między BDC a EMC, raportowany jako rekord, którego MCID łączy go z drzewem struktury
var
  Marks: TPdfContentMarks;
  M: TPdfContentMark;
  P: TPdfContentMarkParam;
  I: Integer;
begin
  Pdf.PageNumber := 1;                    // PageNumber jest liczone od 1
  for I := 0 to Pdf.ObjectCount - 1 do    // indeksy obiektów stron są liczone od 0
  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                     // najpierw znacznik najbardziej zewnętrzny
    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;

Co mówi pmpUnknown, a czego nie powie

Każdy TPdfContentMarkParam ma Key, Kind i jedno pole wartościowe wybrane przez ten rodzaj. Mapowanie jest na tyle krótkie, że zmieści się w głowie:

  • Liczba o wartości całkowitej staje się pmpInt, z ustawionym IntValue i wypełnionym tą samą liczbą FloatValue, więc /MCID 3 i zabłąkane 3.0 czytają się jako liczby całkowite
  • Liczba z częścią ułamkową staje się pmpFloat
  • Ciągi PDF i nazwy PDF stają się pmpString; ciągi tekstowe zaczynające się od znacznika kolejności bajtów UTF-16 (FE FF lub FF FE) są dekodowane do StringValue, więc /ActualText przychodzi jako czytelny tekst
  • Pozostałe typy wartości przychodzą jako pmpBlob, gdy PDFium przekazuje dla nich surowe bajty, a w przeciwnym razie zostają pmpUnknown

pmpUnknown obejmuje dwa przypadki, których nie da się odróżnić. Jednym jest wartość, którą PDFium raportuje jako FPDF_OBJECT_UNKNOWN i dla której nie przekazuje żadnych bajtów. Drugim jest klucz, który figuruje na liście, ale nie daje się odczytać: klucz zapisany bajtami spoza ASCII w uszkodzonym pliku może być na liście i mimo to wrócić jako pmpUnknown. Rekord wygląda tak samo w obu przypadkach

Traktuj pmpUnknown jako "brak użytecznej wartości" i raportuj znacznik, zamiast go akceptować. Jeśli Twój przepływ pracy zależy od obecności /Lang albo /ActualText, szukaj klucza z rodzajem, który rozpoznajesz; nigdy nie wnioskuj z nieznanego rodzaju, że właściwości nie ma

Handle identyfikuje znacznik wewnątrz aktualnie wczytanej strony i staje się nieświeży, gdy tylko znacznik zostanie usunięty, obiekt strony zniszczony albo strona wyładowana. Skopiuj Name i Params do własnych struktur, zanim przełączysz strony. Dla danych, które mają przeżyć aktualną stronę — listy w interfejsie czy audyt w tle — ReadPageObjectsSnapshot zwraca rekordy TPdfContentMarkSnapshot, które trzymają wyłącznie nazwę i parametry, bez żadnego uchwytu. Ta sama reguła dotyczy uchwytów obiektów strony ogólnie, jak opisują notatki o uchwytach obiektów strony stających się nieświeżymi po transformacji

Dodawanie, aktualizacja i usuwanie znaczników

Edycję pokrywają cztery wywołania. AddPageObjectMark(Index, Name, Params) tworzy na obiekcie nowy znacznik, stosuje parametry w kolejności i zwraca indeks nowego znacznika na tym obiekcie, który pozostałe trzy wywołania przyjmują jako MarkIndex. Nazwy podaje się bez ukośnika: 'P', 'Span'. Żeby zmienić jeden klucz, SetPageObjectMarkParam(Index, MarkIndex, Param) wstawia go lub zastępuje i zostawia w spokoju każdy inny parametr znacznika. Usuwanie jest bardziej radykalne: RemovePageObjectMarkParam(Index, MarkIndex, Key) zrzuca klucz, a RemovePageObjectMark(Index, MarkIndex) zrzuca cały znacznik

AddPageObjectMark traktuje znacznik i jego parametry jako jeden krok. Jeśli parametr zostanie odrzucony (rodzaj pmpUnknown, pusty klucz, pusty blob), świeżo utworzony znacznik jest usuwany, zanim wyjątek się rozejdzie, więc obiekt nigdy nie zostaje z tagiem mającym połowę listy właściwości

Każde z tych wywołań przebudowuje strumień treści strony, zanim wróci. To istotne, bo SaveAs zapisuje strumień treści taki, jaki jest, i nie regeneruje go z modelu obiektów PDFium. Znacznik istniejący wyłącznie na obiekcie w pamięci zostawiłby w zapisanym pliku zero operatorów BDC. Jeśli edytujesz obiekty stron także przez surowe importy PDFium na ObjectHandle, wywołaj przed zapisem sam UpdatePage

Diagram dodawania znacznika oznaczonej treści w Delphi: AddPageObjectMark przebudowuje strumień treści strony przed SaveAs, podczas gdy zmiana tylko w modelu obiektowym jest po cichu gubiona w zapisanym pliku
AddPageObjectMark przebudowuje strumień treści, więc SaveAs utrwala znacznik, a zmiana, która zatrzyma się na modelu obiektów, nigdy nie dociera do pliku

Przebudowa całego strumienia przy każdym wywołaniu uchodzi przy kilku edycjach, a marnotrawstwem jest przy kilkuset. Tagowanie nietagowanego raportu strona po stronie to ten drugi przypadek i tam edycje należą do środka BeginPageObjectEditTransaction i CommitPageObjectEditTransaction. Wewnątrz transakcji każda edycja tylko oznacza stronę jako zmienioną; commit generuje strumień treści raz, a RollbackPageObjectEditTransaction przywraca stronę do stanu wyjściowego. Transakcja jest związana z jedną stroną i ustawienie PageNumber na inną stronę, dopóki jest otwarta, kończy się wyjątkiem. Na podpisanym dokumencie wywołanie begin odmawia startu, chyba że przekażesz AllowSignedDocument = True, bo i tak edycja treści strony łamie podpis

Parametry tekstowe wymagają jeszcze jednej decyzji. Wartości pmpString są zapisywane jako ciągi bajtów, co jest dokładnie tym, czego chcą wartości ASCII takie jak /Lang (en-US). Tekst zastępczy z akcentami, ligaturami czy znakami CJK należy do ciągu tekstowego PDF kodowanego jako UTF-16BE z prefiksem FE FF (§7.9.2.2). Przekaż te bajty jako pmpBlob: PDFium trzyma je jako obiekt string, a czytnik powyżej dekoduje je z powrotem do StringValue jako pmpString

// Własna funkcja pomocnicza, nie część biblioteki: ciąg tekstowy PDF jako UTF-16BE z 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;            // zapisywany jako ciąg tekstowy, czyta się z powrotem jako pmpString
    Params[1].BlobValue := Utf16TextString(Spoken);
    MarkIndex := Pdf.AddPageObjectMark(ObjectIndex, 'Span', Params);

    Lang := Default(TPdfContentMarkParam);
    Lang.Key := 'Lang';
    Lang.Kind := pmpString;               // wartość ASCII, zwykły ciąg bajtów wystarczy
    Lang.StringValue := 'de-DE';
    Pdf.SetPageObjectMarkParam(ObjectIndex, MarkIndex, Lang);
  except
    Pdf.RollbackPageObjectEditTransaction;
    raise;
  end;
  Pdf.CommitPageObjectEditTransaction;    // jedna przebudowa strumienia treści dla całej paczki
end;

Commit siedzi poza blokiem try nie bez powodu. Jeśli generowanie treści zawiedzie, CommitPageObjectEditTransaction sam przywraca stronę, zanim rzuci wyjątek ponownie, a drugie wywołanie rollback dorzuciłoby tylko "No page-object edit transaction is active" ponad prawdziwym błędem

Dlaczego jeden akapit kończy się z trzema sekwencjami MCID 5?

Bo każde wywołanie AddPageObjectMark tworzy nowy, niezależny słownik znacznika, nawet gdy wartości są identyczne. Akapit złożony z trzech obiektów tekstowych, po jednym na linijkę, otagowany trzema wywołaniami, z których każde przekazuje /MCID 5, niesie trzy osobne znaczniki. Gdy PDFium regeneruje strumień treści, trzyma kolejne obiekty w jednej parze BDC/EMC tylko wtedy, gdy dzielą ten sam znacznik; porównuje tożsamość znacznika, a równe wartości się nie liczą. Zapisana strona trzyma wtedy /P <</MCID 5>> BDC … EMC trzy razy pod rząd

ISO 32000-1 §14.7.4.2 opisuje MCID jako identyfikator, który wskazuje sekwencję oznaczonej treści w obrębie jej strumienia treści jednoznacznie. Trzy sekwencje dzielące jeden identyfikator nie spełniają tego warunku, a narzędzia rozwiązujące MCID przez wpis drzewa rodzica strony zostają ze zgadywaniem, którą z trzech element struktury ma na myśli

SharePageObjectMark(TargetIndex, SourceIndex, SourceMarkIndex) przypina do obiektu docelowego własny słownik właściwości znacznika źródłowego zamiast budować kopię. Dodaj znacznik raz do pierwszej linijki, współdziel go z pozostałymi i trzymaj obiekty kolejno w porządku rysowania; zregenerowany strumień owinie wtedy wszystkie trzy linijki w jedną sekwencję

procedure TagParagraph(Pdf: TPdf; const LineObjects: array of Integer; Mcid: Integer);
// LineObjects: indeksy obiektów linijek akapitu, kolejne w porządku rysowania
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;

Współdzielenie ma drugą zaletę, gdy kopiujesz istniejący znacznik na nową treść, na przykład rozciągając artefakt na przerysowaną linię stopki. Rodzaje parametrów, które PDFium potrafi zapisać, to liczba całkowita, float, ciąg i blob bajtów, a rodzaju dla nazw nie ma. /Artifact <</Type /Pagination>> przeczytany jako pmpString i zbudowany na nowo przez AddPageObjectMark wychodzi z (Pagination), czyli ciągiem, tam gdzie §14.8.2.2 oczekuje nazwy /Pagination. Współdzielony znacznik to oryginalny słownik, więc jego nazwy pozostają nazwami. Zawsze, gdy znacznik źródłowy już istnieje na stronie, współdziel go; z Params buduj od zera tylko wtedy, gdy zapisujesz znacznik, który nigdy nie istniał

Dopasowywanie MCID do drzewa struktury

MCID na obiekcie strony coś znaczy tylko wtedy, gdy zgłasza go element struktury na tej samej stronie. StructureElementsForPage[PageNumber] zwraca elementy strony jako rekordy TPdfStructureElement, a trzy pola niosą MCID: MarkedContentID (-1, gdy element go nie ma), MarkedContentIDs dla elementów posiadających kilka sekwencji oraz ChildMarkedContentIDs, z -1 w slotach dzieci, które są elementami, a nie oznaczoną treścią. Audyt musi spojrzeć na wszystkie trzy, bo wpis /K elementu struktury może mieszać elementy dzieci i referencje oznaczonej treści w jednej tablicy

Audyt poniżej raportuje dwa rodzaje ustaleń. Tekst lub obrazy, których MCID nie zgłasza żaden element, albo które w ogóle nie mają MCID, to treść, którą technologia asystująca pominie. Artifact, którego MCID jest zgłaszany, przeczy §14.8.2.2 wprost. Obiekty stron pochodzą z ReadPageObjectsSnapshot, więc wyniki nie trzymają uchwytów i przeżyją przełączenie strony

// Własna funkcja pomocnicza, nie część biblioteki
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;

Ścieżki celowo zostały poza zakresem. Ozdobne linie i obramowania tabel często są nietagowane, a flagowanie każdej z nich zagrzeba ustalenia dotyczące tekstu. Form XObjects zasługują na ostrożność odwrotną: MCID wewnątrz formy należy do przestrzeni nazw /StructParents tej formy, więc MCID 0 na stronie i MCID 0 wewnątrz formy to różne sekwencje. Przewodnik po przestrzeniach nazw MCID w otagowanych Form XObjects pokazuje, jak czytać je poprawnie

Znaczniki to wciąż tylko połowa otagowanego dokumentu. ISO 32000-1 §14.7 żąda drzewa struktury, którego elementy referencjonują te MCID, słownika /MarkInfo w katalogu z /Marked true oraz typów struktury będących standardowymi albo zmapowanymi na standardowe przez mapę ról. Pdf.IsTagged mówi Ci, czy katalog deklaruje dokument jako otagowany. Znacznik /P, którego MCID nie wskazuje żaden element, daje treść twierdzącą, że jest otagowana, na stronie, o której drzewo struktury nigdy nie wspomina

Diagram PDFium Component pokazujący, że same znaczniki BDC nie czynią PDF otagowanym: potrzebne są jeszcze drzewo struktury odwołujące się do MCID, deklaracja MarkInfo i standardowe nazwy ról
Znaczniki wspierają audyt i naprawę sierot, podczas gdy zgodny dokument otagowany potrzebuje dodatkowo drzewa struktury, MarkInfo i standardowych nazw ról

Po stronie drzewa struktury przewodnik po walidacji drzewa struktury PDF/UA podejmuje tam, gdzie audyt MCID się kończy. A doświadczenie czytelnicze, dla którego te tagi w ogóle istnieją, to temat notatek o budowie dostępnego czytnika PDF w Delphi

Wywołania użyte tutaj (odczyt znaczników, transakcje edycji, współdzielone znaczniki, migawki obiektów stron i elementy struktury) należą do tego samego API VCL, które PDFium Component wystawia projektom Delphi, C++Builder i Lazarus nad silnikiem PDFium. Strona produktu PDFium Component opisuje resztę API dostępności i edycji stron