Komponent HotPDF wyodrębnia tekst Unicode z dowolnego pliku PDF załadowanego w Delphi za pomocą dwóch wywołań: ExtractLoadedPageText zwraca tekst strony zgodnie z przepływem czytania, a ExtractLoadedPageTextLayout (dodane w wersji 2.263.0) rekonstruuje wizualny układ strony jako zwykły tekst, dzięki czemu kolumny, wcięcia i wyrównanie tabel są zachowywane w pliku wyjściowym. Obie metody działają na dokumentach, których HotPDF nie utworzył, co jest przypadkiem o kluczowym znaczeniu: fakturą wysłaną e-mailem przez klienta, raportem dostarczonym przez biuro skanowania czy umową wygenerowaną przez oprogramowanie, którego nazwy nikt już nie pamięta
Dojście do tego celu wymagało bardziej skomplikowanych mechanizmów, niż sugerują to dwa proste nagłówki funkcji, ponieważ plik PDF nie przechowuje tekstu w taki sam sposób jak zwykły plik tekstowy. Ten artykuł omawia oba tryby wyodrębniania, a następnie zagląda pod maskę trzech znajdujących się pod spodem elementów — czytnika CMap, interpretera strumienia zawartości oraz łańcucha rezerwowego dekodowania fontów — ponieważ wiedza o tym, jak działa mapowanie znaków, pozwala zdiagnozować zniekształcony plik wyjściowy zamiast bezradnego rozłożenia rąk
Dlaczego wyodrębnianie tekstu jest trudniejsze niż zwykłe czytanie ciągów znaków z pliku?
Strumień zawartości PDF rejestruje kody znaków, a nie same znaki. Operatory Tj oraz TJ (ISO 32000-1 §9.4.3) przenoszą ciągi bajtów, których znaczenie zależy wyłącznie od fontu wybranego przez poprzedzający operator Tf: bajt 0x41 może oznaczać literę A w kodowaniu WinAnsi, dowolny glif w czcionce z wydzielonym podzbiorem (subset) lub połowę dwubajtowego identyfikatora CID w złożonym foncie CJK. Norma ISO 32000-1 §9.10 definiuje wyodrębnianie tekstu właśnie jako taki problem dekodowania — odwzorowanie każdego kodu z powrotem na Unicode przy użyciu wszelkich informacji dostarczanych przez słownik fontu — a standard wyraźnie zaznacza, że zgodny plik nie musi dostarczać wystarczających informacji do wykonania tego zadania
To ostatnie stwierdzenie wyjaśnia każdy zgłoszony błąd typu „dlaczego kopiowanie i wklejanie z tego pliku PDF tworzy bełkot”. Generator, który osadza font z wydzielonym podzbiorem, ale bez tabeli /ToUnicode, zapisuje plik, który renderuje się idealnie, ale wyodrębnia się jako bzdury, ponieważ odwzorowanie kodu na glif istnieje, ale odwzorowanie kodu na Unicode nigdy nie zostało dostarczone. Każdy rzetelny interfejs API do wyodrębniania tekstu jest zatem łańcuchem rozwiązań rezerwowych (fallbacks) działających na zasadzie najlepszej próby (best-effort), a kluczowe pytanie brzmi: jak głęboki jest ten łańcuch
Wyodrębnianie tekstu zgodnie z przepływem czytania za pomocą ExtractLoadedPageText
Do celów indeksowania wyszukiwania, dopasowywania słów kluczowych lub przekazywania tekstu do potoku analizy, wywołanie ExtractLoadedPageText jest tym, czego potrzebujesz. Sygnatura to function ExtractLoadedPageText(PageIndex: Integer; out AText: UnicodeString): boolean — indeksy stron są liczone od zera, wynik jest dostarczany jako natywny typ Delphi UnicodeString, a funkcja zwraca wartość False, gdy strona nie ma czytelnego strumienia zawartości, zamiast zgłaszać wyjątek
var
Pdf: THotPDF;
PageCount, I: Integer;
PageText, AllText: UnicodeString;
begin
Pdf := THotPDF.Create(nil);
try
PageCount := Pdf.LoadFromFile('invoice.pdf');
AllText := '';
for I := 0 to PageCount - 1 do
if Pdf.ExtractLoadedPageText(I, PageText) then
AllText := AllText + PageText + #13#10;
// AllText now holds the reading-flow text of the document
finally
Pdf.Free;
end;
end;
Podziały wierszy w wyjściowym tekście wynikają z celowo prostej heurystyki: gdy pionowy punkt początkowy glifu przesuwa się o więcej niż połowę bieżącego rozmiaru fontu — co jest sygnaturą kroku Td lub T* w strumieniu zawartości — wstawiany jest znak nowej linii. Znaki, których dekoder nie może rozwiązać, stają się spacjami zamiast znikać, dzięki czemu granice słów zostają zachowane, nawet jeśli pojedyncze glify nie zostaną rozszyfrowane. Ten tryb nie podejmuje prób grupowania według kolejności czytania ani wykrywania wielu kolumn: strona dwukolumnowa zostanie wyodrębniona w kolejności zapisanej w strumieniu zawartości, co zazwyczaj, ale nie zawsze, odpowiada kolejności wizualnej
Kiedy zamiast tego należy użyć wyodrębniania z zachowaniem układu strony?
Metoda ExtractLoadedPageTextLayout jest odpowiednim wyborem zawsze wtedy, gdy pozycja na stronie niesie ze sobą znaczenie: w przypadku tabel, formularzy, listingów kodu, wszystkiego, co zamierzasz porównywać (diff), przeszukiwać (grep) lub przetwarzać kolumnami. Zamiast spłaszczać glify do jednego strumienia, grupuje je w linie bazowe, sortuje każdą linię wzdłuż osi X i odtwarza poziome oraz pionowe odstępy na siatce znaków o stałej szerokości, dobranej na podstawie mediany szerokości glifu i rozmiaru fontu. Szerokie odstępy między fragmentami tekstu na tej samej linii bazowej stają się spacjami; duże odstępy między liniami bazowymi stają się pustymi wierszami. Wynik czyta się tak, jak wygląda strona
var
Grid: UnicodeString;
begin
if Pdf.ExtractLoadedPageTextLayout(0, Grid) then
TFile.WriteAllText('page1.txt', Grid, TEncoding.UTF8);
// Columns, indentation and table alignment survive as
// spaces and blank lines on a character grid
end;
Oba tryby dzielą każdą linię kodu mechanizmu dekodowania i różnią się jedynie sposobem rozmieszczenia zdekodowanych glifów, więc ten wybór nie wpływa na wierność odwzorowania znaków. Wybierz ExtractLoadedPageText, gdy liczą się tylko same słowa, a ExtractLoadedPageTextLayout, gdy znaczenie ma ich układ. Wykrywanie kolejności czytania w układzie wielokolumnowym pozostaje poza zakresem obu funkcji — renderowanie siatki strony dwukolumnowej pokazuje obie kolumny obok siebie, co dla porównywania plików jest idealne, ale dla ponownego układania prozy już nie
Jak HotPDF dekoduje kody znaków na Unicode?
Komponent HotPDF rozpoznaje każdy kod znaku poprzez priorytetowy łańcuch rezerwowy: najpierw wbudowaną w font tabelę mapowania /ToUnicode CMap, następnie wpis /Encoding (strumień lub nazwaną tabelę CMap), potem — dla fontów kompozytowych — standardowe pliki CMap firmy Adobe dla kolekcji znaków, takich jak Adobe-GB1, Adobe-CNS1, Adobe-Japan1 i Adobe-KR, a na końcu wbudowane tabele WinAnsi i MacRoman dla prostych fontów. Strategia, która nie może dostarczyć odpowiedzi, przechodzi po cichu do kolejnej, zamiast zgłaszać błąd, a kod, który wyczerpie cały łańcuch, jest mapowany na 0, dzięki czemu wywołujący może policzyć chybienia zamiast zgdywać
Tabela /ToUnicode CMap (ISO 32000-1 §9.10.3) znajduje się na początku, ponieważ jest to mapowanie zapisane przez program generujący plik specjalnie na potrzeby ekstrakcji. Ścieżka standardowych tabel CMap firmy Adobe ma znaczenie w przypadku dokumentów CJK, które używają predefiniowanych map CMap, takich jak UniGB-UTF16-H, zamiast cokolwiek osadzać: HotPDF dostarcza pliki kolekcji w swoim katalogu resources\CMap, lokalizuje je w czasie rzeczywistym względem pliku wykonywalnego i buforuje każdą sparsowaną mapę w pamięci procesu — co warto wiedzieć, ponieważ największa z nich, mapa Adobe-GB1, to około 2 MB tekstu źródłowego, którego nie chcesz przetwarzać na nowo dla każdej strony. W przypadku braku tego katalogu dekoder po prostu pomija mapy CMap zapisane na dysku i pracuje z wbudowanymi tabelami oraz domyślnymi kodowaniami. Jest to lustrzane odbicie problemu formowania tekstu opisanego w formowaniu tekstu w pismach złożonych za pomocą HotPDF, gdzie to samo rozróżnienie kodu od glifu występuje przy zapisie
Dwie pułapki składni CMap, które warto znać
Pliki CMap wyglądają na łatwe do sparsowania, ale wcale takie nie są, a za większość niepowodzeń przy pierwszej próbie napisania parsera odpowiadają dwa szczegóły. Pierwszym z nich jest to, że liczba rekordów występuje przed słowem kluczowym sekcji: sekcja brzmi 2 beginbfchar, a nie beginbfchar 2. Parser oczekujący liczby po słowie kluczowym potraktuje cyfrę jako pojedynczy token, a następnie znajdzie zero wpisów w każdej sekcji. Solidne podejście — to, na które zdecydował się czytnik HotPDF — polega na całkowitym zignorowaniu tej liczby i zapętleniu odczytu do napotkania słowa kluczowego endbfchar / endbfrange, co dodatkowo pozwala na obsługę plików, w których te liczby są po prostu błędne
Drugą pułapką jest to, że cele w bfchar i bfrange są łańcuchami UTF-16BE, a nie liczbami całkowitymi. Cel <D83DDE00> oznacza kod U+1F600 — parę surogatów, która musi zostać połączona w jeden punkt kodowy — a odczytanie tych czterech bajtów jako liczby całkowitej typu big-endian daje bezużyteczną wartość dla każdego punktu kodowego spoza Basic Multilingual Plane. Znaki Emoji w plikach PDF nie są już rzadkością, więc dekoder pomijający łączenie surogatów zawiedzie na plikach, z którymi faktycznie pracują Twoi użytkownicy. HotPDF analizuje literał szesnastkowy najpierw do surowych bajtów, a następnie łączy jednostki kodowe UTF-16BE, co obejmuje również cele wieloznakowe tworzone przez mapowanie ligatur
Schodzenie do poziomu glifów za pomocą ExtractLoadedPageGlyphs
Oba wywołania tekstowe są oparte na funkcji ExtractLoadedPageGlyphs, a leżąca u podstaw tablica THPDFGlyphArray jest również dostępna dla Twojego kodu. Każdy rekord THPDFGlyphRecord przenosi rozpoznany punkt kodowy Unicode wraz z surowym kodem znaku, szerokością bajtową kodu (1, 2 lub 4, określaną przez codespacerange tabeli CMap), rozmiarem i kluczem aktywnego zasobu fontu, pozycją początkową X i Y w przestrzeni użytkownika oraz szerokością znaku (horizontal advance). To wystarczy, aby zbudować detekcję granic słów, pozycjonowane podświetlanie lub własny algorytm układu graficznego bez konieczności samodzielnego analizowania strumienia zawartości
var
Glyphs: THPDFGlyphArray;
I, Unresolved: Integer;
begin
if Pdf.ExtractLoadedPageGlyphs(0, Glyphs) then
begin
Unresolved := 0;
for I := 0 to High(Glyphs) do
if Glyphs[I].Unicode = 0 then
Inc(Unresolved);
if Unresolved > 0 then
ShowMessageFmt('%d of %d glyphs have no Unicode mapping',
[Unresolved, Length(Glyphs)]);
end;
end;
Które pliki PDF nie pozwolą na wyodrębnienie tekstu?
Zeskanowane dokumenty są najprostszym przypadkiem: strona będąca jednym dużym obrazem nie zawiera w ogóle operatorów tekstowych, więc wyodrębnianie poprawnie zwraca pusty ciąg znaków — rozwiązaniem jest OCR, a wyodrębnienie obrazów stron z załadowanego pliku PDF to pierwszy krok w tym potoku. Fonty z wydzielonym podzbiorem (subset) bez tabeli /ToUnicode to trudniejszy przypadek: jeśli ścieżka /Encoding i standardowe mapy CMap również okażą się puste, glify te zostaną zmapowane na 0 i pojawią się jako spacje w wywołaniach tekstowych. Szyfrowane dokumenty wyodrębniają się normalnie pod warunkiem, że załadujesz je wraz z hasłem za pomocą przeciążonej metody LoadFromFile, dzięki czemu strumienie zostaną odszyfrowane, zanim interpreter w ogóle je zobaczy
Warto jasno określić jedno węższe ograniczenie: łańcuch dekodowania odczytuje mapy CMap i strumienie zawartości poprzez ścieżkę Flate komponentu HotPDF, więc font, którego strumień ToUnicode używa nietypowego filtra, przejdzie do następnej strategii zamiast powodować błąd całej strony. W praktyce FlateDecode obejmuje prawie wszystko, co powstało w ciągu ostatnich dwóch dekad, a degradacja ta odbywa się domyślnie po cichu — otrzymujesz najlepszy tekst, na jaki pozwala plik, zamiast zgłoszenia wyjątku. Te same mechanizmy obiektów po stronie odczytu, które rozwiązują słowniki fontów w tym miejscu, zasilają również funkcję edycji metadanych w załadowanych dokumentach, dzięki czemu potok wejściowy dokumentu może wyodrębniać, sprawdzać i dodawać adnotacje w jednym przebiegu
Wyodrębnianie tekstu, renderowanie z zachowaniem układu, dostęp na poziomie glifów oraz oparte na nich funkcje wyszukiwania i zamiany są częścią standardowego pakietu HotPDF Component dla Delphi i C++Builder — bez zewnętrznych bibliotek DLL, bez systemowych usług tekstowych, to po prostu czysty Object Pascal, który możesz debugować krok po kroku, gdy w Twojej kolejce pojawi się nietypowy plik