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ć
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 ustawionymIntValuei wypełnionym tą samą liczbąFloatValue, więc/MCID 3i zabłąkane3.0czytają 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 FFlubFF FE) są dekodowane doStringValue, więc/ActualTextprzychodzi 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
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
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