Технічна стаття

Вибір текстового рядка PDF з блоками символів PDFium у Delphi

Текстова сторінка 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. Потрійне клацання засіває його з якоря вибору; клавіші shift засівають його з курсора вибору й рухають лише цей кінець; 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 текстового індексу, використаними в прикладах; сторінка продукту несе повну довідку для моделі текстової сторінки й вибору