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

Переглядач PDF з безперервною прокруткою в Delphi за допомогою PDFium Component

Одна сторінка формату A4, відрендерена при комфортному масштабі для читання, займає кілька мегабайтів у вигляді 32-бітної бітової карти. Помножте це на 400-сторінковий контракт, і арифметика перестане бути абстрактною: якщо відрендерити кожну сторінку заздалегідь, ви попросите у Windows більше гігабайта пам'яті для бітових карт, які користувач переглядатиме по одному екрану за раз. Програма або вичерпає адресний простір у 32-бітній збірці, або зависне на кілька секунд, поки графічний процесор та парсер сторінок оброблятимуть дані, до яких користувач ще навіть не докрутив. Переглядач із безперервною прокруткою має виглядати як одна довга стрічка сторінок, але він не може тримати їх усі в пам'яті одночасно

Ця дилема і є головною проблемою. Компонент PDFium Component вирішує її всередині TPdfView, тому велика частина роботи полягає у виборі правильного режиму відображення та розумінні того, що саме компонент робить замість вас. Ті частини, які він не виконує автоматично — визначення розміру сторінок для читання та забезпечення швидкої реакції при прокрутці — вимагають написання невеликої кількості коду. Якщо ви все ще створюєте інтерфейс навколо переглядача (панель інструментів, ескізи, поле пошуку), про це детально написано в покроковому створенні багатофункціонального переглядача; тут же ми зосередимося безпосередньо на самій прокрутці

Макет — це режим відображення, а не панель бітових карт

Звичайний інстинкт при роботі з формами VCL — використовувати контейнер прокрутки (scroll box) та розміщувати в ньому компоненти зображень один за одним для кожної сторінки. Не робіть цього. Такий підхід змусить вас самостійно вирішувати завдання позиціонування сторінок, розрахунку прокрутки та керування пам'яттю, і ви реалізуєте кожен із цих механізмів неефективно. Компонент TPdfView вже представляє документ як безперервну послідовність сторінок і дозволяє налаштовувати макет через властивість DisplayMode

Pdf := TPdf.Create(Self);
PdfView := TPdfView.Create(Self);
PdfView.Parent := Self;
PdfView.Align := alClient;
PdfView.Pdf := Pdf;

PdfView.DisplayMode := dmSingleContinuous;   // one page wide, scrolls vertically

Pdf.FileName := 'contract.pdf';
Pdf.Active := True;
if not Pdf.Active then
  ShowMessage('Could not open the document');

Це все, що потрібно для налаштування безперервної прокрутки. Режим dmSingleContinuous вибудовує сторінки в один вертикальний стовпець із внутрішньою обробкою проміжків між ними, і вікно перегляду прокручує цей стовпець як єдину область. Для звичайної навігації не потрібно створювати елементи керування для кожної сторінки чи писати власні обробники прокрутки. Зверніть увагу на перевірку властивості Pdf.Active після призначення: відкриття документа ніколи не викликає винятків, тому пошкоджений або захищений паролем файл просто залишить Active у значенні False без виникнення помилок, а переглядач, який пропускає цю перевірку, покаже пусту панель, змушуючи вас думати, що помилка в коді

Ця ж властивість підтримує режими розворотів. Режим dmTwoPageContinuous розміщує сторінки поруч, по дві в ряд, що зручно для книжкового читання деяких документів; dmTwoPageContinuousWithCover робить те саме, але залишає першу сторінку окремо як обкладинку, щоб наступні розвороти формували природні пари парних і непарних сторінок. Усі три режими підтримують безперервну прокрутку. Перемикання між ними — це просте призначення властивості, тому ви легко зможете додати випадаючий список вибору режиму пізніше

Растеризуються тільки видимі сторінки

Причина, чому цей метод масштабується до 400-сторінкових файлів, полягає в тому, що стовпець сторінок є віртуальним. Компонент TPdfView знає висоту кожної сторінки з дерева сторінок документа, тому може обчислити загальний розмір прокрутки та положення кожної сторінки, нічого не рендеричи заздалегідь. Растеризація — ресурсомісткий крок, який перетворює потік вмісту сторінки на пікселі — виконується лише для тих сторінок, які зараз перетинають область перегляду, плюс невеликий запас, щоб сторінка була готова до моменту її появи. Коли ви прокручуєте документ вниз, сторінки, що з'являються на екрані, рендериться, а бітові карти сторінок, що зникають, видаляються з пам'яті. Обсяг використаної пам'яті пропорційний тому, що поміщається на екрані, а не загальній довжині документа

Це важливо розуміти, оскільки це змінює підхід до оцінки продуктивності. Відкриття 400-сторінкового документа виконується швидко: аналізується структура, а не вміст. Витрати ресурсів відбуваються посторінково і відкладено (lazy), саме в той момент, коли сторінка наближається до області перегляду. Переглядач, який відкривається миттєво і прокручується плавно, не робить менше роботи загалом — він просто розподіляє її по шляху прокручування та відкидає те, що залишається позаду. Практичний висновок: вам майже ніколи не потрібно примусово рендерити сторінки наперед. Дозвольте вікну перегляду самому вирішувати, що має бути видимим

Масштабування сторінок за шириною

Для зручного читання сторінки мають відповідати ширині панелі перегляду, а не мати жорстко заданий масштаб. Режим FitMode забезпечує це і зберігає пропорції при зміні розміру вікна

PdfView.FitMode := pfmFitWidth;   // each page fills the column width; height follows

У режимі pfmFitWidth компонент перераховує масштаб щоразу, коли змінюється розмір вікна перегляду, тому стовпець завжди заповнює доступну ширину, а висота сторінок і загальний розмір прокрутки підлаштовуються автоматично. Тут є одна пастка: пряме присвоєння властивості Zoom скидає FitMode назад у pfmNone. Це зроблено навмисно, оскільки ручний масштаб і автопідгонка суперечать один одному, але це означає, що випадковий виклик PdfView.Zoom := 1.0 десь у вашому коді тихо вимкне автопідгонку по ширині, і наступна зміна розміру вікна не оновить макет. Якщо ви пропонуєте користувачеві як ручний масштаб, так і кнопку автопідгонки, ставтеся до них як до перемикача режимів: увімкнення одного вимикає інше

Для точного керування масштабом компонент надає значення масштабу підгонки як властивості, які можна застосувати або відобразити: PageWidthZoom[PageNumber] повертає масштаб для підгонки цієї сторінки по ширині, а відповідне значення PageZoom підганяє всю сторінку. Використання цих значень дозволяє створювати меню «За шириною» / «Уся сторінка» без жорстко закодованих відсотків, які не працюватимуть на альбомних або нестандартних сторінках

Забезпечення швидкої прокрутки за допомогою прогресивного рендерингу

За замовчуванням процес рендерингу повністю промальовує сторінку перед поверненням керування. Для однієї сторінки це нормально. Але при швидкій прокрутці великого документа це створює проблеми: кожна сторінка, що з'являється на мить, запускає повну растеризацію, і якщо користувач прокручує документ швидше, ніж встигають створюватися зображення сторінок, ці запити накопичуються, викликаючи затримки відображення, оскільки робота виконується для сторінок, які вже зникли з екрана. Вирішенням є реалізація можливості скасування рендерингу та відмова від нього, щойно користувач прокрутив далі

Метод RenderPageProgressive виконує рендеринг частинами та перевіряє токен скасування на кожній межі кроку, тому поточний рендеринг сторінки, яка щойно зникла з екрана, можна скасувати замість завершення її промальовування

type
  TFormMain = class(TForm)
    // ...
  private
    FRenderCancel: IPdfCancellationTokenSource;
    procedure RenderPageToBitmap(PageNo: Integer; Bmp: TBitmap);
  end;

procedure TFormMain.RenderPageToBitmap(PageNo: Integer; Bmp: TBitmap);
var
  Status: TPdfProgressiveStatus;
begin
  // Скасувати поточний рендеринг; старий токен тепер сигналізує про скасування.
  if Assigned(FRenderCancel) then
    FRenderCancel.Cancel;
  FRenderCancel := TPdfCancellationTokenSource.New;

  Pdf.PageNumber := PageNo;
  Status := Pdf.RenderPageProgressive(Bmp, 0, 0, Bmp.Width, Bmp.Height,
    FRenderCancel.Token);

  case Status of
    prsDone:      ;                    // бітова карта готова, малюємо її
    prsCancelled: Exit;                // скасовано іншим запитом, ігноруємо результат
    prsFailed:    ShowMessage('Помилка рендерингу сторінки ' + IntToStr(PageNo));
  end;
end;

Головне тут — це значення, що повертається. prsDone означає, що бітова карта повністю намальована та готова для виведення на екран; prsCancelled вказує на те, що нова позиція прокрутки скасувала обробку цієї сторінки, тому частковий результат слід відкинути; prsFailed повідомляє про помилку на цій сторінці. Перевірка скасування відбувається на межах кроків обробки, а не миттєво, тому затримка між викликом Cancel та реальною зупинкою рендерингу може становити кілька десятків мілісекунд. Це все одно набагато швидше, ніж дозволити застарілому запиту заблокувати чергу. Передача значення nil замість токена виконує рендеринг повністю без можливості скасування, що є правильним вибором для разових завдань на кшталт попереднього перегляду перед друком

Якщо замість цього ви використовуєте функціональний варіант RenderPage, який повертає новий об'єкт TBitmap, пам'ятайте, що вихідний код володіє цим об'єктом і має викликом Free звільняти його. У циклі прокрутки, де створюється бітова карта для кожної сторінки, забути про це означає отримати витік пам'яті, що збільшується з кожною переглянутою сторінкою — а це саме те виснаження пам'яті, якого безперервний макет мав запобігти. По можливості рендерите дані у вже створену бітову карту повторно

Підсумок

Основну частину логіки безперервної прокрутки бере на себе сам компонент. Ви вибираєте режим dmSingleContinuous для макета, встановлюєте pfmFitWidth для автоматичного підлаштування під ширину вікна та перевіряєте Pdf.Active для обробки некоректних файлів. Єдина частина, яку варто реалізувати самостійно — це скасований рендеринг, оскільки зручність програми оцінюється саме за тим, як вона поводиться, коли користувач швидко перетягує повзунок прокрутки вниз великого документа. Усе інше — виділення тексту на кількох сторінках, підсвічування результатів пошуку, дерево закладок — це інтерфейсні завдання, які створюються поверх цієї області прокрутки, а не всередині неї

Інтерфейси API TPdfView, DisplayMode та RenderPageProgressive, показані тут, є частиною компонента PDFium Component для Delphi та Lazarus