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