Техническая статья

Параллельное сравнение PDF в Delphi с помощью компонента PDFium

Два документа, открытые одновременно, на одной и той же странице, каждый в своей прокручиваемой панели: это основа средства просмотра для сравнения. Компонент PDFium предоставляет это с помощью простой объектной модели, где TPdf владеет файлом, а TPdfView владеет отображением. Один документ, один TPdf, один TPdfView. Если вам нужно три панели, у вас будет три пары. Сложности заключаются не в вызовах API, а в арифметике макета при изменении размера окна и логике синхронизации страниц, когда вы решаете, какое представление за каким должно следовать

Макет формы

Форма VCL содержит три контейнера TScrollBox рядом друг с другом, каждый с TPdfView внутри, выровненным по alClient, чтобы заполнить поле. Два компонента TSplitter расположены между полями, чтобы пользователь мог настраивать ширину столбцов во время выполнения. Панель инструментов над панелями содержит кнопки открытия, элементы управления масштабированием и переключатель режима двух/трех представлений

Режим трех представлений — это логическое значение (boolean), которое форма отслеживает внутри. Когда он переключается, вы пересчитываете ширину и показываете или скрываете третий столбец. Самый простой подход — очистить все свойства Align, скрыть разделители, а затем задать абсолютные позиции:

procedure TFormMain.UpdateLayout;
var
  TotalWidth: Integer;
begin
  TotalWidth := ClientWidth;

  if ThreeViewMode then
  begin
    ScrollBox3.Visible := True;
    ScrollBox1.Left   := 0;
    ScrollBox1.Width  := TotalWidth div 3;
    ScrollBox2.Left   := ScrollBox1.Width;
    ScrollBox2.Width  := TotalWidth div 3;
    ScrollBox3.Left   := ScrollBox2.Left + ScrollBox2.Width;
    ScrollBox3.Width  := TotalWidth - ScrollBox3.Left;
    // Apply the same (ClientHeight - toolbar height) to all three Height values
  end
  else
  begin
    ScrollBox3.Visible := False;
    ScrollBox1.Left   := 0;
    ScrollBox1.Width  := TotalWidth div 2;
    ScrollBox2.Left   := ScrollBox1.Width;
    ScrollBox2.Width  := TotalWidth - ScrollBox2.Left;
  end;
end;

Установка Align := alNone для всех трех полей перед целочисленной арифметикой позволяет избежать того, чтобы механизм ограничений VCL боролся с вашими назначениями. Восстановите видимость разделителей после позиционирования, если вам нужно изменение размера перетаскиванием в режиме двух представлений

Высота каждого поля с прокруткой равна клиентской области минус высота панели инструментов. Поскольку панель инструментов прикреплена сверху с помощью alTop, ClientHeight - PanelButtons.Height дает вам полезное вертикальное пространство. Назначьте это всем трем полям внутри одного вызова UpdateLayout, чтобы никогда не было кадра, где одно поле выше других, что вызывает мерцание макета

Открытие документа

Каждой паре панелей нужна собственная процедура открытия. Шаблон короткий: деактивировать компонент, задать имя файла, активировать, затем проверить Active; если оно осталось False, запросить пароль и повторить попытку. Обратите внимание, что TPdfView.Active — это то, что управляет рендерингом, но именно TPdf.Active фактически открывает файл; они независимы. Установка PdfView.Active := True, когда связанный с ним TPdf еще не активен, безвредна, но ничего не отображает

procedure TFormMain.OpenPdfFile(PdfComponent: TPdf;
  PdfViewComponent: TPdfView);
var
  Password: string;
begin
  if not OpenDialog.Execute then
    Exit;

  PdfComponent.Active   := False;
  PdfComponent.FileName := OpenDialog.FileName;
  PdfComponent.Password := '';
  PdfComponent.Active   := True;

  // Load failures are silent: Active stays False instead of raising.
  if not PdfComponent.Active then
  begin
    // Most likely a password-protected file; give the user one retry.
    if InputQuery('Password', 'Enter document password:', Password) then
    begin
      PdfComponent.Password := Password;
      PdfComponent.Active   := True;
    end;
  end;

  if not PdfComponent.Active then
  begin
    ShowMessage('Could not open ' + OpenDialog.FileName +
      ' (damaged file or wrong password)');
    Exit;
  end;

  PdfViewComponent.PageNumber := 1;
  SetActivePdfView(PdfViewComponent);
end;

Всегда проверяйте PdfComponent.Active после назначения; поврежденный файл или неверный пароль приводят к сбою загрузки без вызова исключения по умолчанию. Явная установка PdfViewComponent.PageNumber := 1 после успешного открытия позволяет избежать сохранения номера страницы из предыдущего документа

Диалоговое окно сообщения в конце намеренно: вы хотите, чтобы поврежденные или неподдерживаемые файлы обнаруживались немедленно, а не поглощались как тихая пустая панель. Пользователь, который ничего не видит, понятия не имеет, загрузился ли файл и просто пуст, или компонент отклонил его. Сообщение о сбое оставляет ошибку видимой

Отслеживание активной панели

Когда пользователь щелкает внутри панели, эта панель становится активной. Форма отслеживает закрытое (private) поле FActivePdfView: TPdfView. Визуальная обратная связь — это изменение цвета границы содержащего TScrollBox: установите для него clHighlight для активного и clWindow для остальных. Привяжите это к каждому TPdfView.OnClick и к процедуре открытия, чтобы фокус следовал за документом, который вы только что открыли

Некоторые операции применяются ко всем видимым панелям, а не только к активной. Этим управляет логическое значение FAllViewsMode на форме. Когда оно истинно, изменения масштаба и навигация по страницам распространяются на каждую панель, в которой есть активный документ:

procedure TFormMain.ApplyZoomToAll(NewZoom: Double);
begin
  if PdfView1.Active then PdfView1.Zoom := NewZoom;
  if PdfView2.Active then PdfView2.Zoom := NewZoom;
  if ThreeViewMode and PdfView3.Active then PdfView3.Zoom := NewZoom;
end;

Синхронизированная навигация по страницам

Синхронизированная навигация необязательна, но полезна для рабочих процессов проверки документов, когда оба файла охватывают один и тот же диапазон страниц. Логика принадлежит обработчику событий, который срабатывает после того, как пользователь перемещается в одном представлении. Когда исходное представление изменяет свой PageNumber, обработчик передает это число в другие представления при одном условии: целевое представление должно иметь как минимум столько же страниц, иначе оно пропускается

PageNumber в TPdfView и в TPdf независимы. TPdf.PageNumber отслеживает, какую страницу компонент документа считает текущей; TPdfView.PageNumber отслеживает то, что отображается на экране. Для целей навигации вам нужно свойство представления, а не документа

Флажок с надписью типа «Синхронизировать страницы» ("Sync pages") дает пользователю контроль. Когда он снят, каждая панель выполняет навигацию независимо, и обработчик немедленно завершает работу. Эта независимость важна для случаев использования, когда два документа имеют разное количество страниц или когда пользователь хочет найти эквивалентный отрывок в переводе, который начинается на другой странице. Постоянное принудительное включение синхронизации сделает инструмент сложнее в использовании, чем простое расположение двух окон на рабочем столе

За чем следует следить: программная установка PdfView.PageNumber внутри обработчика синхронизации сама по себе вызовет событие изменения для этого представления. Защититесь от бесконечной рекурсии с помощью логического флага, который вы устанавливаете перед назначением и очищаете сразу после. Флаг относится к форме, а не к представлению, поскольку все три представления используют один и тот же обработчик

Масштаб для каждой панели

Каждый TPdfView содержит собственное свойство Zoom типа Double в процентах, где Zoom := 100 означает реальный размер (100%). Его установка переопределяет любой активный FitMode. Для кнопки «По ширине» (fit-to-width) на активной панели считайте масштаб подгонки из PdfView.PageWidthZoom[PdfView.PageNumber] и назначьте его. Для кнопки «По странице» (fit-to-page) используйте PageZoom[PageNumber]. Оба являются свойствами-массивами, индексируемыми по номеру страницы, начинающемуся с 1, поэтому защититесь от нулевого номера страницы перед доступом к ним

Когда вы экспортируете текущую страницу в изображение, считайте поворот из представления, но вызовите RenderPage для компонента TPdf, а не для представления. Растровая форма TPdf.RenderPage принимает явные размеры в пикселях, а также значение TRotation и набор TRenderOptions. Вариант функции возвращает принадлежащий вызывающей стороне TBitmap, который вы освобождаете самостоятельно после сохранения:

procedure TFormMain.SaveActiveViewAsImage;
var
  Pdf: TPdf;
  Bmp: TBitmap;
  Jpeg: TJpegImage;
begin
  if not Assigned(FActivePdfView) or not FActivePdfView.Active then
    Exit;

  Pdf := FActivePdfView.Pdf;
  Pdf.PageNumber := FActivePdfView.PageNumber;

  Bmp := Pdf.RenderPage(
    0, 0,
    Round(Pdf.PageWidth * 2),
    Round(Pdf.PageHeight * 2),
    FActivePdfView.Rotation, [], clWhite);
  try
    if SavePictureDialog.Execute then
    begin
      Jpeg := TJpegImage.Create;
      try
        Jpeg.Assign(Bmp);
        Jpeg.CompressionQuality := 90;
        Jpeg.SaveToFile(SavePictureDialog.FileName);
      finally
        Jpeg.Free;
      end;
    end;
  finally
    Bmp.Free;
  end;
end;

Множитель 2x для ширины и высоты дает более четкий вывод для документов с мелким текстом. Блок try/finally вокруг освобождения растрового изображения не является необязательным; отмена TSaveDialog по-прежнему попадает в блок finally, и вы хотите, чтобы растровое изображение было освобождено независимо от того, что сделал пользователь

Требования к DLL

Компонент PDFium оборачивает нативную библиотеку pdfium. Для 32-битного хост-процесса требуется pdfium32.dll; для 64-битного — pdfium64.dll. Варианты с JavaScript-движком V8 добавляют суффикс v8 и весят примерно 23-27 МБ по сравнению с 5-6 МБ у стандартных сборок. Для средства просмотра для сравнения, в котором отключено заполнение форм (Pdf.FormFill := False), стандартной сборки без V8 будет достаточно, и она сохранит меньший размер дистрибутива

Поместите DLL в тот же каталог, что и исполняемый файл, или в любой каталог в системном PATH. Компонент загружает ее по требованию при активации первого TPdf, поэтому отсутствующая DLL обнаруживается в этот момент, а не при запуске приложения. Если вы поставляете установщик, самый надежный подход — скопировать DLL в папку приложения во время установки, а не полагаться на системный каталог, который администратор может позже очистить

Сборки V8 в первую очередь полезны, когда вам нужно взаимодействовать с JavaScript-действиями PDF, например, для запуска вычисляемых полей или обработчиков отправки. Пассивное средство просмотра для сравнения не имеет причин запускать JavaScript; установка Pdf.FormFill := False перед Active := True полностью пропускает среду заполнения форм, что также означает, что JS-движок не инициализируется, даже если используется стандартная сборка. Это правильное значение по умолчанию для средства просмотра только для чтения (read-only), независимо от того, какой вариант DLL вы поставляете

Для получения более подробной информации о компоненте PDFium и его полном API посетите страницу продукта Delphi PDFium Component