Текстовая страница PDF предоставляет символы и боксы, но никогда строки. PDFium Component строит визуальную строку, кластеризуя боксы символов, чьи вертикальные центры попадают в пределах половины высоты символа-затравки, сканируя наружу от кликнутого символа, пока не будет превышен допуск. Каждый путь выделения в просмотрщике вызывает этот единственный помощник, так что мышь, клавиатура и код согласны друг с другом
Симптом, приводящий к поискам этого решения, конкретен и неприятен. Пользователь тройным кликом выделяет абзац в двухколоночном отчёте и получает половину страницы. Или тройной клик по ячейке таблицы, и выделение проглатывает всю строку плюс номер страницы в футере. Просмотрщик не сломан; он задаёт вопрос, на который файл не может ответить. В PDF нет строки для выделения, и любая реализация, притворяющаяся иначе, гадает. Эта статья о том, как сделать это гадание осознанным и последовательным. Если вам на самом деле нужно извлечь текст из документа, смотрите статью извлечение текста из документов PDF с PDFium; если вы верстаете текст и вам нужны ширины, смотрите измерение текста и перенос по словам. Здесь предмет уже: определение того, где начинается и заканчивается визуальная строка, и выделение именно её
Почему у текстовой страницы PDF нет объектов строк?
Потому что поток содержимого PDF описывает рисование, а не структуру. ISO 32000-1 §9.4 определяет текстовый объект как пару BT / ET, содержащую операторы позиционирования и показа. Операторы позиционирования из §9.4.2 (Td, TD, Tm, T*) двигают текстовую матрицу по странице, а операторы показа из §9.4.3 (Tj, TJ, ', ") рисуют глифы там, куда сейчас указывает эта матрица. Ничто в этой модели не говорит «эта последовательность глифов — строка». Строка — это то, что видит человек после того, как рисование завершено
Производители усложняют это способами, которые вы не контролируете. Выровненный по ширине абзац может быть выдан как один массив TJ на строку, или как один Tj на слово с явным Tm перед каждым, или как единая операция показа с корректировками кернинга, несущими интервалы. Двухколоночная вёрстка может выдать левую колонку сверху вниз, а затем правую, или может чередовать их, если производитель обходил свой собственный внутренний список объектов в другом порядке. Последовательность символов, которую вам выдаёт PDFium, следует потоку содержимого, а поток содержимого следует тому, что вздумалось делать генерирующему приложению. Так что две функции, которые вы на самом деле получаете, — это FPDFText_CountChars, сообщающая, сколько символов содержит страница, и FPDFText_GetCharBox, возвращающая ограничивающий бокс одного символа в пространстве страницы. Это весь сырой словарь. Всё, что выше него, — слова, строки, абзацы, колонки, — это вывод, который вы делаете по геометрии
Почему обнаружение CR и LF — неверный тест?
Потому что символы, по которым вы стали бы тестировать, не присутствуют надёжно, а когда они присутствуют, они не надёжно ваши. PDFium вставляет синтетические символы в текстовую страницу, чтобы сделать извлечённый текст читаемым: пробел там, где два прогона визуально разделены, CR или LF там, где следующий прогон начинается на новой базовой линии. FPDFText_IsGenerated существует именно для того, чтобы отличить их от символов, пришедших из файла, и PDFium Component предоставляет это как свойство CharacterGenerated
Разбейте по этим символам, и вы унаследуете каждое решение, которое принял PDFium при их синтезе. Жёсткий разрыв строки внутри перенесённого абзаца и мягкий перенос выглядят идентично после синтеза. Строка таблицы, которую производитель выдал по ячейке за раз, может вообще не получить разрыва между последней ячейкой и первой ячейкой следующей строки, потому что базовые линии оказались достаточно близко. Между тем заголовок, за которым следует основной текст другого размера, может получить два разрыва там, где человек видит один. Сгенерированные символы — удобство рендеринга для извлечения текста со всей страницы; это не модель строк, и они деградируют как раз в тех документах, где выделение важнее всего
Кластеризация боксов символов по вертикальному центру
Надёжный сигнал — геометрия. Возьмите символ, по которому кликнул пользователь, как затравку, вычислите вертикальный центр его бокса и идите наружу в обоих направлениях, пока соседние боксы держат свои вертикальные центры в пределах допуска. PDFium Component использует половину высоты бокса затравки как этот допуск, с нижним порогом в 0.5 единиц страницы, так что вырожденные боксы — точка, тонкий пробел, глиф с почти нулевой высотой бокса — не схлопывают допуск до ничего и не обрезают строку после одного символа
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;
Три детали в этом цикле заслуживают своего места. Допуск выводится из затравки, а не из константы, так что заголовок в 24pt получает широкую полосу, а сноска в 7pt — узкую, и ни один не крадёт символы у другого. Сравнение использует вертикальные центры, а не базовые линии или верхушки боксов, что удерживает надстрочный индекс, встроенный прогон другого размера или предложение со смешанными шрифтами на одной строке с их соседями. А неудавшийся FPDFText_GetCharBox завершает сканирование, а не пропускается, потому что символ без извлекаемой геометрии не даёт доказательства ни в ту, ни в другую сторону, и продолжение мимо него позволило бы обходу перескочить через настоящую границу на основании символа далее по тексту
Почему каждый путь выделения должен разделять одного помощника?
Потому что три пути кода, каждый реализующий «строку» самостоятельно, разойдутся, и разойдутся тихо. В PDFium Component расширение по тройному клику, Shift+Home, Shift+End и публичный метод SelectLineAt — все разрешают свои границы через один и тот же вызов LineRangeAt. Тройной клик задаёт затравку из якоря выделения; клавиши со сдвигом задают затравку из курсора выделения и двигают только этот конец; SelectLineAt задаёт затравку из переданного вызывающим кодом индекса символа и передаёт результат в SelectTextRange, тот же валидатор диапазона, что использует путь мыши. Дублируйте логику вместо этого, и сбой будет не крахом, а медленным дрейфом. Кто-то настраивает допуск тройного клика, чтобы исправить отчёт с плотным межстрочным интервалом, и теперь Shift+End останавливается на один символ раньше, чем останавливается тройной клик на том же абзаце. Пользователь выделяет строку мышью, расширяет её клавиатурой и наблюдает, как выделение сжимается. Поскольку SelectLineAt питает обычный конвейер выделения, программное выделение также остаётся независимым от того, включён ли ввод мыши, и всё равно получает бесплатно проверку диапазона, перерисовку и уведомление OnSelectionChange
// 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;
Обратите внимание на аргументы допуска в CharacterIndexAtPos. У проверки попадания есть собственный люфт, выраженный в единицах страницы, и это отдельная забота от допуска строки. Клик, попавший в межстрочный интервал между двумя строками, разрешается к тому символу, что ближе всего в пределах этого бокса; затем сканирование строки выполняется от того символа, каким бы он ни оказался. Подача слишком щедрого допуска попадания в затравку — один из более простых способов выделить строку, на которую пользователь не указывал
Два пространства индексов: индекс символа и текстовый индекс
Как только у вас есть диапазон, устоите перед искушением использовать его как смещение строки. FPDFText_GetText возвращает текст страницы как буфер UTF-16, но его индексы — не то же пространство индексов, что индексы символов, используемые FPDFText_GetCharBox и FPDFText_CountChars. Обсуждавшиеся ранее сгенерированные символы сидят в текстовом буфере, занимая слоты символов без пригодной геометрии, и обе нумерации расходятся по мере продвижения по странице. Мосты между ними — FPDFText_GetTextIndexFromCharIndex и FPDFText_GetCharIndexFromTextIndex, обёрнутые PDFium Component как CharacterIndexToTextIndex и 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;
Направление, кусающееся сильнее всего, — обратное. Поиск, реализованный по извлечённой строке, даёт вам текстовые индексы, и передача их напрямую в API боксов или выделения молча адресует не те символы, с ошибкой, растущей чем дальше вниз по странице вы идёте. Преобразуйте через TextIndexToCharacterIndex, прежде чем что-либо геометрическое коснётся числа. Суррогатные пары добавляют вторую, независимую проблему смещения поверх этой, разобранную в статье об эмодзи, CJK и суррогатных парах
Где эвристика ломается
Будьте честны с собой насчёт пределов, потому что они реальны и достижимы. Повёрнутый текст — самый явный случай: бокс символа — это выровненный по осям прямоугольник в пространстве страницы, так что для текста, повёрнутого на 90 градусов, боксы одной визуальной строки имеют вертикальные центры, разбросанные по странице, и сканирование останавливается почти немедленно. Вы получаете короткое выделение, а не неверное, что является лучшим режимом сбоя, но всё же сбоем. Вертикальные режимы письма ведут себя так же по той же причине. Двухколоночная вёрстка работает, когда колонки вертикально смещены друг относительно друга, и ломается, когда нет. Если обе колонки разделяют сетку базовых линий, символы из правой колонки попадают в допуск строки левой колонки, и сканирование пройдёт прямо через промежуток между колонками, потому что в чистой геометрии там нечему остановить его. Обнаружение этого требует теста горизонтального разрыва поверх вертикальной кластеризации, а выбор порога разрыва — своё собственное суждение о том, в каких документах вы готовы ошибаться. Смешанные размеры шрифтов — случай, с которым допуск, относительный к затравке, справляется хорошо: встроенный фрагмент кода на 8pt внутри основного текста на 11pt держит свой центр внутри полосы, а заголовок на 24pt на следующей базовой линии не втягивает строку основного текста в себя
Описанная здесь семантика выделения строк поставляется в PDFium Component для Delphi и C++Builder, наряду с API проверки попадания, диапазона выделения и текстового индекса, использованными в примерах; страница продукта содержит полный справочник по текстовой странице и модели выделения