Artykuł techniczny

Zaznaczanie linii tekstu PDF za pomocą char box PDFium w Delphi

Strona tekstowa PDF udostępnia znaki i ich ramki, nigdy linie. PDFium Component buduje linię wizualną, grupując ramki znaków, których pionowe środki mieszczą się w połowie wysokości znaku zalążkowego, skanując na zewnątrz od klikniętego znaku, aż tolerancja zostanie przekroczona. Każda ścieżka zaznaczania w przeglądarce wywołuje ten jeden helper, więc mysz, klawiatura i kod się zgadzają

Objaw, który przywodzi Cię do szukania tego, jest konkretny i nieprzyjemny. Użytkownik potrójnie klika akapit w dwukolumnowym raporcie i dostaje pół strony. Albo potrójnie klika komórkę tabeli, a zaznaczenie połyka cały wiersz plus numer strony w stopce. Przeglądarka nie jest zepsuta; zadaje pytanie, na które plik nie potrafi odpowiedzieć. W PDF-ie nie ma linii do zaznaczenia, a jakakolwiek implementacja udająca inaczej zgaduje. Ten artykuł dotyczy uczynienia tego zgadywania celowym i spójnym. Jeśli faktycznie potrzebujesz wyciągnąć tekst z dokumentu, zobacz ekstrakcję tekstu z dokumentów PDF za pomocą PDFium; jeśli rozmieszczasz tekst i potrzebujesz szerokości, zobacz pomiar tekstu i zawijanie wierszy. Tutaj temat jest węższy: decydowanie, gdzie linia wizualna się zaczyna i kończy, i zaznaczanie dokładnie tego

Dlaczego strona tekstowa PDF nie ma obiektów linii?

Ponieważ strumień treści PDF opisuje rysowanie, nie strukturę. ISO 32000-1 §9.4 definiuje obiekt tekstowy jako parę BT / ET zawierającą operatory pozycjonowania i pokazywania. Operatory pozycjonowania z §9.4.2 (Td, TD, Tm, T*) przesuwają macierz tekstu po stronie, a operatory pokazywania z §9.4.3 (Tj, TJ, ', ") malują glify tam, gdzie akurat wskazuje ta macierz. Nic w tym modelu nie mówi „ten ciąg glifów to linia”. Linia to coś, co człowiek widzi po zakończeniu malowania

Producenci pogarszają to w sposoby, których nie kontrolujesz. Wyjustowany akapit może być wyemitowany jako jedna tablica TJ na linię, albo jako jedno Tj na słowo z jawnym Tm przed każdym, albo jako pojedyncza operacja pokazywania z korektami kerningowymi niosącymi odstępy. Układ dwukolumnowy może wyemitować lewą kolumnę od góry do dołu, a potem prawą, albo może je przeplatać, jeśli producent przechodził swoją własną wewnętrzną listę obiektów w innej kolejności. Sekwencja znaków, którą podaje Ci PDFium, podąża za strumieniem treści, a strumień treści podąża za tym, na co akurat miała ochotę generująca aplikacja. Więc dwie funkcje, które faktycznie dostajesz, to FPDFText_CountChars, która raportuje, ile znaków trzyma strona, i FPDFText_GetCharBox, która zwraca ramkę graniczną jednego znaku w przestrzeni strony. To całe surowe słownictwo. Wszystko ponad nim, słowa, linie, akapity, kolumny, to wnioskowanie, które wykonujesz na geometrii

Dlaczego wykrywanie CR i LF jest złym testem?

Ponieważ znaki, wobec których byś testował, nie są niezawodnie obecne, a gdy są obecne, nie są niezawodnie Twoje. PDFium wstrzykuje syntetyczne znaki do strony tekstowej, by uczynić wyekstrahowany tekst czytelnym: spację tam, gdzie dwa przebiegi są wizualnie rozdzielone, CR albo LF tam, gdzie kolejny przebieg zaczyna się na nowej linii bazowej. FPDFText_IsGenerated istnieje dokładnie po to, byś mógł odróżnić je od znaków, które wyszły z pliku, a PDFium Component udostępnia to jako właściwość CharacterGenerated

Podziel po tych znakach, a odziedziczysz każdą decyzję, jaką PDFium podjęło, je syntetyzując. Twarde złamanie linii wewnątrz zawijanego akapitu i miękkie zawinięcie wyglądają identycznie po syntezie. Wiersz tabeli, który producent wyemitował komórka po komórce, może w ogóle nie dostać złamania między ostatnią komórką a pierwszą komórką następnego wiersza, ponieważ linie bazowe akurat są wystarczająco blisko siebie. Tymczasem nagłówek, po którym następuje treść w innym rozmiarze, może dostać dwa złamania tam, gdzie człowiek widzi jedno. Generowane znaki to udogodnienie renderujące dla ekstrakcji całej strony; nie są modelem linii, i degradują się dokładnie w tych dokumentach, w których zaznaczanie ma największe znaczenie

Grupowanie ramek znaków po pionowym środku

Wiarygodnym sygnałem jest geometria. Weź znak, który kliknął użytkownik, jako zalążek, oblicz pionowy środek jego ramki i przechodź na zewnątrz w obu kierunkach, dopóki sąsiadujące ramki utrzymują swoje pionowe środki w tolerancji. PDFium Component używa połowy wysokości ramki zalążkowej jako tej tolerancji, z dolnym progiem 0,5 jednostki strony, tak by zdegenerowane ramki, kropka, cienka spacja, glif z ramką o niemal zerowej wysokości, nie zapadały tolerancji do niczego i nie ucinały linii po jednym znaku

function TPdfView.LineRangeAt(TxtPage: FPDF_TEXTPAGE; CharIndex: Integer;
  out StartIndex, Count: Integer): Boolean;
var
  Lo, Hi, Total: Integer;
  SeedBox, Box: TPdfRectangle;
  SeedYMid, BoxYMid, HalfH: Double;
begin
  Result := False;
  StartIndex := -1;
  Count := 0;
  Total := FPDFText_CountChars(TxtPage);
  if (CharIndex < 0) or (CharIndex >= Total) then
    Exit;

  if FPDFText_GetCharBox(TxtPage, CharIndex, SeedBox.Left, SeedBox.Right,
    SeedBox.Bottom, SeedBox.Top) = 0 then
    Exit;
  SeedYMid := (SeedBox.Top + SeedBox.Bottom) / 2;
  HalfH := Abs(SeedBox.Top - SeedBox.Bottom) / 2;
  if HalfH < 0.5 then          // floor for degenerate boxes
    HalfH := 0.5;

  Lo := CharIndex;
  Hi := CharIndex;
  while Lo > 0 do
  begin
    if FPDFText_GetCharBox(TxtPage, Lo - 1, Box.Left, Box.Right,
      Box.Bottom, Box.Top) = 0 then
      Break;
    BoxYMid := (Box.Top + Box.Bottom) / 2;
    if Abs(BoxYMid - SeedYMid) > HalfH then
      Break;
    Dec(Lo);
  end;
  while Hi < Total - 1 do
  begin
    if FPDFText_GetCharBox(TxtPage, Hi + 1, Box.Left, Box.Right,
      Box.Bottom, Box.Top) = 0 then
      Break;
    BoxYMid := (Box.Top + Box.Bottom) / 2;
    if Abs(BoxYMid - SeedYMid) > HalfH then
      Break;
    Inc(Hi);
  end;
  StartIndex := Lo;
  Count := Hi - Lo + 1;
  Result := True;
end;

Trzy szczegóły w tej pętli zarabiają na swoje miejsce. Tolerancja wyprowadza się z zalążka zamiast ze stałej, więc 24-punktowy nagłówek dostaje szerokie pasmo, a 7-punktowy tekst przypisu wąskie, i żaden nie kradnie znaków drugiemu. Porównanie używa pionowych środków zamiast linii bazowych albo górnych krawędzi ramek, co utrzymuje indeks górny, przebieg w innym rozmiarze inline, albo zdanie w mieszanej czcionce na tej samej linii co jego sąsiedzi. A nieudane FPDFText_GetCharBox kończy skanowanie zamiast być pominięte, ponieważ znak bez odzyskiwalnej geometrii nie daje Ci żadnego dowodu w żadną stronę, a kontynuowanie poza nim pozwoliłoby przejściu przeskoczyć przez prawdziwą granicę na podstawie znaku dalej w ciągu

Dlaczego każda ścieżka zaznaczania musi dzielić jeden helper?

Ponieważ trzy ścieżki kodu, z których każda implementuje „linię”, rozjadą się, i rozjadą się cicho. W PDFium Component potrójne kliknięcie rozszerzające, Shift+Home, Shift+End, i publiczna metoda SelectLineAt wszystkie rozwiązują swoje granice przez to samo wywołanie LineRangeAt. Potrójne kliknięcie zasila je z kotwicy zaznaczenia; klawisze shift zasilają je z kursora zaznaczenia i przesuwają tylko ten koniec; SelectLineAt zasila je z indeksu znaku dostarczonego przez wywołującego i przekazuje wynik do SelectTextRange, tego samego walidatora zakresu, którego używa ścieżka myszy. Zduplikuj tę logikę zamiast tego, a awaria to nie crash, to powolny dryf. Ktoś stroi tolerancję potrójnego kliknięcia, by naprawić raport z ciasnym interlinią, i teraz Shift+End zatrzymuje się jeden znak przed miejscem, gdzie zatrzymuje się potrójne kliknięcie na tym samym akapicie. Użytkownik zaznacza linię myszą, rozszerza ją klawiaturą i patrzy, jak zaznaczenie się kurczy. Ponieważ SelectLineAt zasila zwykły pipeline zaznaczania, zaznaczanie programowe też pozostaje niezależne od tego, czy wejście myszy jest włączone, i wciąż dostaje walidację zakresu, przemalowanie i powiadomienie OnSelectionChange za darmo

// Select the visual line under a client-space point, then read it back
procedure TForm1.SelectLineUnderCursor(X, Y: Integer);
var
  CharIndex: Integer;
begin
  CharIndex := PdfView1.CharacterIndexAtPos(X, Y, 6.0, 6.0);
  if CharIndex < 0 then
    Exit;
  if PdfView1.SelectLineAt(PdfView1.CurrentPage, CharIndex) then
    Memo1.Lines.Add(PdfView1.SelectedText);
end;

Zauważ argumenty tolerancji w CharacterIndexAtPos. Testowanie trafienia ma własny luz, wyrażony w jednostkach strony, i jest to osobna sprawa od tolerancji linii. Kliknięcie, które ląduje w interlinii między dwiema liniami, rozwiązuje się do tego znaku, który jest najbliżej w obrębie tej ramki; skanowanie linii uruchamia się potem od tego, jaki znak to okazał się być. Podanie zbyt hojnej tolerancji trafienia do zalążka to jeden z łatwiejszych sposobów, by zaznaczyć linię, na którą użytkownik nie wskazywał

Dwie przestrzenie indeksów: indeks znaku i indeks tekstu

Mając już zakres, oprzyj się pokusie użycia go jako offsetu ciągu znaków. FPDFText_GetText zwraca tekst strony jako bufor UTF-16, ale jego indeksy nie są tą samą przestrzenią indeksów co indeksy znaków używane przez FPDFText_GetCharBox i FPDFText_CountChars. Generowane znaki omówione wcześniej siedzą w buforze tekstu, jednocześnie zajmując sloty znaków bez użytecznej geometrii, a te dwa numerowania rozjeżdżają się na przestrzeni strony. Mostami są FPDFText_GetTextIndexFromCharIndex i FPDFText_GetCharIndexFromTextIndex, opakowane przez PDFium Component jako CharacterIndexToTextIndex i TextIndexToCharacterIndex

var
  TextStart, TextEnd: Integer;
begin
  // char-index range from LineRangeAt -> offsets into the page text buffer
  TextStart := Pdf.CharacterIndexToTextIndex(StartIndex);
  TextEnd   := Pdf.CharacterIndexToTextIndex(StartIndex + Count - 1);
  if (TextStart >= 0) and (TextEnd >= TextStart) then
    Caption := Pdf.Text(TextStart, TextEnd - TextStart + 1);
end;

Kierunek, który gryzie najmocniej, to ten odwrotny. Wyszukiwanie zaimplementowane nad wyekstrahowanym ciągiem znaków daje Ci indeksy tekstu, a przekazanie ich prosto do API ramki albo zaznaczenia po cichu adresuje niewłaściwe znaki, z błędem rosnącym, im dalej w dół strony zajdziesz. Konwertuj za pomocą TextIndexToCharacterIndex, zanim cokolwiek geometrycznego dotknie tej liczby. Pary surogatów dodają na to drugi, niezależny problem przesunięcia, omówiony w artykule o emoji, CJK i parach surogatów

Gdzie heurystyka się ugina

Bądź ze sobą uczciwy co do granic, ponieważ są realne i osiągalne. Obrócony tekst to najwyraźniejszy przypadek: ramka znaku to prostokąt wyrównany do osi w przestrzeni strony, więc dla tekstu obróconego o 90 stopni ramki jednej linii wizualnej mają pionowe środki rozrzucone po stronie, a skanowanie zatrzymuje się niemal natychmiast. To, co dostajesz, to krótkie zaznaczenie, a nie błędne, co jest lepszym trybem awarii, ale wciąż jest awarią. Tryby pisma pionowego zachowują się tak samo z tego samego powodu. Układy dwukolumnowe działają, gdy kolumny są przesunięte pionowo względem siebie, i psują się, gdy nie są. Jeśli obie kolumny współdzielą siatkę linii bazowych, znaki z prawej kolumny siedzą w tolerancji linii lewej kolumny, a skanowanie przebiegnie prosto przez rynnę międzykolumnową, ponieważ w czystej geometrii nie ma niczego, na czym mogłoby się zatrzymać. Wykrycie tego wymaga testu poziomej przerwy ponad pionowym grupowaniem, a wybór progu przerwy to własna decyzja o tym, w których dokumentach chcesz się mylić. Mieszane rozmiary czcionek to przypadek, z którym tolerancja względna wobec zalążka radzi sobie dobrze: inline'owy 8-punktowy fragment kodu wewnątrz 11-punktowej treści zachowuje swój środek wewnątrz pasma, a 24-punktowy nagłówek na następnej linii bazowej nie wciąga w siebie linii treści

Opisana tu semantyka zaznaczania linii jest dostarczana w PDFium Component dla Delphi i C++Builder, obok API testowania trafienia, zakresu zaznaczenia i indeksu tekstu użytych w przykładach; strona produktu niesie pełną dokumentację modelu strony tekstowej i zaznaczania