Artykuł techniczny

Ekstrakcja tekstu PDF w Delphi: spacje i końce linii

HotPDF Delphi Component odbudowuje spacje wyrazów i końce linii w THotPDF.ExtractLoadedPageText z geometrii glifów, a nie ze znaków spacji. Spacja wchodzi, gdy odstęp za własną szerokością glifu przekracza 0.15 wysokości tekstu, a nowa linia zaczyna się tylko wtedy, gdy początek tekstu przesunie się w poprzek kierunku pisania o więcej niż połowę wysokości tekstu. Od v2.768.3 tekst strony obejmuje też tekst malowany przez Form XObjects i pomija glify poza widocznym obszarem crop. Reszta tego tekstu wyjaśnia, dlaczego każda reguła wygląda tak, jak wygląda, bo każda z nich wymieniła prostszą regułę, która na prawdziwych dokumentach produkowała wiarygodny, ale zły wynik

Objawy zna każdy, kto karmił tekst PDF-ów indeksem wyszukiwania. Strona tytułowa ekstrahuje się jako PDFReferenceManualNovember4,1998, formularz podatkowy rozpada się na 156 linii, ukośny znak wodny przychodzi znak na linię, a przycięty proof zaczyna się od linii slug drukarni, której żadna przeglądarka nie pokazuje. Żaden z tych plików nie jest zepsuty. Każdy używa w pełni legalnego sposobu pozycjonowania tekstu, który naiwny ekstraktor odczyta źle

Dlaczego wyekstrahowany tekst PDF traci spacje między wyrazami?

Wyekstrahowany tekst traci spacje, bo PDF nigdy nie musi ich zawierać. Producent może rozdzielać wyrazy pokazując znak spacji, ale równie dobrze może przesunąć pióro liczbą wewnątrz tablicy TJ (ISO 32000-1 §9.4.3) albo świeżym Td (§9.4.2), i dokładnie tak robi wyjście TeX-a, wiele plików Distillera i większość layoutów justowanych. Przed v2.766.76 HPDFAssemblePageText patrzył tylko na ruch pionowy, więc złamanie wyrazu zrobione pozycjonowaniem po prostu znikało. Assembler mierzy teraz, wzdłuż kierunku pisania poprzedniego glifu, odległość od końca własnej szerokości tego glifu do początku bieżącego glifu i wstawia jedną spację, gdy odległość przekracza 0.15 wysokości pudełka bieżącego glifu, mierzonej od ascendentu do descendentu w przestrzeni użytkownika. Spacja nie jest dodawana, gdy któraś strona jest już pusta, ani między dwoma znakami CJK, bo justowanie rozciąga ideogramy, a to rozciągnięcie nie znaczy granicy wyrazu. Rekordy glifów wystawiają tę samą geometrię, więc możesz odtworzyć decyzję, gdy konkretny plik cię zaskoczy

uses
  SysUtils, HPDFDoc, HPDFContentStream;

procedure DumpWordGaps(Pdf: THotPDF; PageIndex: Integer);
var
  Glyphs: THPDFGlyphArray;
  I: Integer;
  Height, Gap: Double;
begin
  if not Pdf.ExtractLoadedPageGlyphs(PageIndex, Glyphs) then
    Exit;
  for I := 1 to High(Glyphs) do
  begin
    // wysokość pudełka glifu od ascendentu do descendentu, w przestrzeni użytkownika
    Height := Sqrt(Sqr(Glyphs[I].QuadX[3] - Glyphs[I].QuadX[0]) +
      Sqr(Glyphs[I].QuadY[3] - Glyphs[I].QuadY[0]));
    // tekst poziomy: odstęp od końca własnej szerokości poprzedniego glifu
    Gap := Glyphs[I].BaselineStartX - Glyphs[I - 1].GlyphEndX;
    if (Height > 0) and (Gap > 0.15 * Height) then
      Writeln(Format('U+%.4x gap %.2f height %.2f: space',
        [Glyphs[I].Unicode, Gap, Height]));
  end;
end;

Dlaczego mierzyć od własnej szerokości glifu, a nie od pozycji pióra?

HotPDF mierzy odstępy wyrazów od GlyphEndX / GlyphEndY, bo pozycja pióra po glifie zawiera już odstępy, które odstępem nie są. ISO 32000-1 §9.4.4 definiuje przesunięcie poziome jako szerokość glifu razy rozmiar fontu, plus odstęp znakowy Tc, plus odstęp wyrazowy Tw, wszystko przeskalowane przez Tz. BaselineEndX / BaselineEndY trzymają to pełne przesunięcie, podczas gdy GlyphEndX / GlyphEndY trzymają tylko posuw fontu i Tz. Różnica ma znaczenie dla producentów, którzy ściskają tracking ujemnym Tc, a potem oddają miejsce korektą TJ po każdym glifie: mierzone od pozycji pióra oddawanie wygląda jak odstęp i chiński termin “95后” ekstrahował się jako “9 5 后”. Próg jest przywiązany do wysokości pudełka glifu, a nie do rozmiaru Tf, z podobnego powodu. Eksporty Worda często piszą 1 Tf i niosą prawdziwy rozmiar w przeskalowanym Tm, więc Tfs mówi 1, podczas gdy tekst ma 10 punktów, i reguła kluczowana do Tfs traktowałaby obie pisownie tej samej strony różnie

Reguła spacji wyrazów HotPDF dla ExtractLoadedPageText w Delphi: spacja jest wstawiana tylko, gdy odległość od GlyphEndX poprzedniego glifu do BaselineStartX następnego przekracza 0.15 wysokości pudełka od ascendentu do descendentu, bo pozycja pióra w BaselineEndX zawiera już Tc, Tw i Tz i zamienia oddawanie trackingu justowanego na fałszywe odstępy jak 9 5 后
O miejscach łamania wyrazów decyduje geometria, a nie znaki spacji — rekordy glifów wystawiają te same pomiary, więc możesz powtórzyć decyzję dla każdego zagadkowego pliku

Reguła ma uczciwe krawędzie. Nagłówek złożony z bardzo luźnym trackingiem, gdzie samo Tc otwiera więcej niż 0.15 wysokości tekstu między literami, ekstrahuje się ze spacją między każdą literą, co jest tym, jak strona wygląda, ale chyba nie tym, co chciałeś indeksować. Kawałki rysowane w złej kolejności na jednej linii bazowej dają ujemny odstęp i sklejają się bez spacji. Żaden z tych przypadków nie jest częsty w tekście głównym, a na korpusie testowym zmiana podniosła trafienia wyrazów względem ekstraktora referencyjnego na 28 stronach, nie obniżając żadnego

Kiedy HotPDF zaczyna nową linię w wyekstrahowanym tekście?

Od v2.766.79 nowa linia zaczyna się, gdy przesunięcie od początku poprzedniego glifu do bieżącego, rzutowane na normalną poprzedniego kierunku pisania, przekracza połowę większej z dwóch wysokości pudełek. Wcześniejsza reguła porównywała surowy ruch Y z połową Tfs, co zawodziło w dwóch kierunkach. Przy 1 Tf i przeskalowanym Tm przymknął się on do połowy jednostki, więc indeks górny podniesiony text rise 0.4 albo zwykłe drganie linii bazowej łamały linię. Reguła ignorowała też całkiem X, więc tekst pod obróconym Tm schodził po stronie z każdym glifem i wychodził glif na linię. Rzutowanie na normalną kierunku sprawia, że obrócone ciągi zachowują się jak poziome, a branie większej z dwóch wysokości trzyma duże słowo próbki i jego mały podpis w jednej linii, gdy dzielą linię bazową. Na wspomnianym formularzu podatkowym liczba linii spadła ze 156 do 97. Tekst pionowy w trybie pisania 1 (§9.7.4.3) idzie osobną ścieżką: te glify są grupowane w kolumny, czytane z prawej do lewej i z góry na dół, z końcem linii przy każdej zmianie kolumny

Jak ExtractLoadedPageText w HotPDF rozstrzyga końce linii w Delphi: przesunięcie między początkami glifów jest rzutowane na normalną kierunku pisania i porównywane z połową większej wysokości pudełka, więc indeks górny podniesiony małym text rise pod fontem 1 Tf i tekst schodzący po stronie pod obróconym Tm nie rozpadają się już na glif na linię
Rzutowanie sprawia, że obrócone ciągi zachowują się jak poziome, a branie większej z dwóch wysokości pudełek trzyma duże słowo próbki i jego mały podpis w jednej linii

Który tekst ExtractLoadedPageText obejmuje, a który zostawia?

ExtractLoadedPageText zwraca tekst, jaki pokazuje przeglądarka. Od v2.766.80 pracuje wyłącznie na widocznych glifach, wyrzucając każdy glif, którego środek pudełka wypada poza GetLoadedPageVisibleBox, czyli CropBox przycięty do MediaBoxa (§14.11.2). To usuwa linie slug i inne znaczniki drukarskie złożone jako tekst poza obszarem cięcia. ExtractLoadedPageGlyphs z premedytacją dalej zwraca każdy glif strumienia treści strony, więc ten materiał znajdziesz, gdy go potrzebujesz. Filtr to test pudełka, nie test widoczności: tekst ukryty ścieżką przycinającą, namalowany na biało albo przykryty obrazem nadal jest ekstrahowany

var
  Pdf: THotPDF;
  Glyphs: THPDFGlyphArray;
  PageText: UnicodeString;
  L, B, R, T: Single;
begin
  Pdf := THotPDF.Create(nil);
  try
    Pdf.LoadFromFile('trimmed-proof.pdf');
    if Pdf.GetLoadedPageVisibleBox(0, L, B, R, T) then
      Writeln(Format('Visible box: %.1f %.1f %.1f %.1f', [L, B, R, T]));
    // każdy glif strumienia treści strony, linia slug wliczona
    if Pdf.ExtractLoadedPageGlyphs(0, Glyphs) then
      Writeln(Length(Glyphs), ' glyphs in the page content stream');
    // tylko to, co strona pokazuje, z wklejonym tekstem Form XObject
    if Pdf.ExtractLoadedPageText(0, PageText) then
      Writeln(PageText);
  finally
    Pdf.Free;
  end;
end;

Tekst malowany przez Form XObjects jest częścią tekstu strony od v2.768.3. Nagłówki, pieczątki i znaki wodne bardzo często mieszkają w formularzach, a niektóre dokumenty norm traciły przed zmianą 30 do 35 procent znaków. THotPDF.InterpretContentWithForms zapamiętuje każde Do razem z obowiązującym CTM, interpretuje formularz przy jego /Matrix razy to CTM (§8.10.1) i wkleja glify formularza w miejscu Do, rekurencyjnie zchodząc do zagnieżdżonych formularzy. Formularz bez własnych /Resources pożycza te od strumienia, który go maluje, na co pozwala §7.8.3. Glify formularzy niosą TokenIndex = -1, a ExtractLoadedPageGlyphs nadal zwraca wyłącznie glify strumienia strony, bo szukanie, zamiana i redakcja zapisują zmiany z powrotem przez TokenIndex i edytowałyby złe bajty, gdyby glif formularza się wkradł. Dwa uproszczenia warto znać: tekst formularza nie jest przycinany do /BBox formularza, a rekurencja stopuje na 12 poziomach, zamiast przez wykrywanie cykli, więc zdeformowany formularz malujący sam siebie powtarza swój tekst, aż dosięgnie tego limitu

Które glify HotPDF obejmuje przy ekstrakcji tekstu strony PDF w Delphi: ExtractLoadedPageText zachowuje tylko glify, których środek pudełka wypada wewnątrz GetLoadedPageVisibleBox, czyli CropBoxa przyciętego do MediaBoxa, więc linie slug drukarki znikają, podczas gdy InterpretContentWithForms wkleja glify Form XObject na każdym Do z TokenIndex ustawionym na -1, a API na poziomie glifów nadal zwraca wszystko
Test pudełka na środku glifu to nie test widoczności — biały tekst, tekst przycięty i przykryty nadal wychodzi, a tekst formularzy liczy się od v2.768.3

Dlaczego tekst po operatorze Q dekodował się jako śmieci?

Tekst po Q mógł się dekodować źle przed v2.766.73, bo ekstraktor zapamiętywał na q tylko CTM. Parametry stanu tekstu, a więc font, rozmiar, Tc, Tw, Tz, TL, tryb renderowania i podniesienie, należą do stanu grafiki (§9.3.1), więc Q musi je przywrócić razem ze wszystkim innym na stosie (§8.4.2). Jeden branżowy raport wybierał dwubajtowy font Identity-H wewnątrz q … Q, a potem pokazywał jednobajtowy tekst WinAnsi bez własnego Tf. Ekstraktor trzymał wewnętrzny font, czytał kropki wiodące i słowo “Adobe” w spisie treści jako kody dwubajtowe i gubił 15% znaków strony. Stos q/Q interpretera trzyma teraz pełny stan tekstu. Reguły ekstrakcji opisane tutaj dotyczą każdej strony, więc cały dokument może pójść do pliku jednym wywołaniem

var
  Output: TFileStream;
  Pages: Integer;
begin
  Output := TFileStream.Create('report.txt', fmCreate);
  try
    // pusty zakres = wszystkie strony; form feed między stronami; BOM UTF-8
    Pages := Pdf.ExtractLoadedPagesTextToStream(Output, '', #12, True);
    Writeln(Pages, ' pages extracted');
  finally
    Output.Free;
  end;
end;

Którego API tekstowego HotPDF użyć?

ExtractLoadedPageText zostaje w kolejności strumienia treści, która jest właściwym domyślnym wyborem dla szukania i indeksowania; łańcuch dekodowania pod spodem jest omówiony w artykule o ekstrakcji tekstu z wczytanych PDF-ów w HotPDF. Dla dokumentów otagowanych, gdzie kolejność autorstwa ma znaczenie, ekstrakcja tekstu w kolejności struktury idzie drzewem struktury zamiast zgadywać z geometrii, a dla danych uwięzionych w tabelach typowana ekstrakcja tabel przez łamania stron zwraca komórki, a nie linie. Pełna referencja API i pobranie wersji próbnej są na stronie produktu HotPDF Delphi PDF Component