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

Конвертация RTF в PDF в Delphi с помощью библиотеки losLab PDF

Формат RTF существует достаточно долго, чтобы появляться там, где никто не планировал: в устаревших генераторах отчетов, конвейерах слияния почты, архивах юридических документов, которые предшествуют современным текстовым процессорам. Преобразование его в PDF на лету является частым требованием, и подход, который действительно работает в Windows, заключается не в использовании специализированного парсера RTF, а в пути рендеринга, который сама Windows уже предоставляет через TRichEdit и EM_FORMATRANGE. Библиотека losLab PDF в виде DLL предоставляет виртуальный контекст устройства, который напрямую встраивается в этот конвейер

Механизм: виртуальный DC и EM_FORMATRANGE

Элементы управления Rich Edit могут разбивать свое содержимое на страницы для любого контекста устройства (DC), а не только для физического принтера. Сообщение EM_FORMATRANGE указывает элементу управления расположить диапазон символов в заданном DC и возвращает позицию последнего символа, который удалось поместить. Вызывайте его повторно, каждый раз увеличивая cpMin, и вы получите постраничный вывод. GetCanvasDC библиотеки losLab PDF предоставляет DC в памяти с размерами страницы, которые вы укажете; после рендеринга страницы в него LoadFromCanvasDc захватывает результат как страницу PDF. Вот и весь конвейер

С самого начала нужно усвоить одну вещь: элемент управления TRichEdit должен быть изменен в размерах в соответствии с целевой страницей. Если элемент управления меньше или больше размеров DC, разбиение на страницы не будет совпадать с тем, что окажется в PDF. Для вывода в формате A4 стандартный подход заключается в установке пиксельных размеров элемента управления в соответствии с 210 x 297 мм при 96 DPI перед загрузкой файла RTF, используя те же помощники масштабирования, которые вы будете использовать для изменения размера DC

Реализация на Delphi

Далее используется модуль импорта PDFlibAX_TLB, который оборачивает DLL-версию библиотеки. Форма содержит TRichEdit и кнопку; обработчик OnCreate формы изменяет размер элемента управления и загружает RTF, а нажатие кнопки запускает цикл преобразования

unit MainUnit;

interface

uses
  Windows, Messages, SysUtils, Classes, Graphics, Controls, Forms,
  Dialogs, StdCtrls, ComCtrls, PDFlibAX_TLB, ActiveX;

type
  TForm1 = class(TForm)
    RichEdit1: TRichEdit;
    Button1: TButton;
    procedure FormCreate(Sender: TObject);
    procedure Button1Click(Sender: TObject);
  private
    function PrintRtfBox(hDc: HDC; rtfBox: TRichEdit;
      FirstChar: Integer): Integer;
  end;

var
  Form1: TForm1;
  PdfDoc: TPDFLibrary;

implementation

{$R *.dfm}

procedure TForm1.FormCreate(Sender: TObject);
begin
  PdfDoc := TPDFLibrary.Create(Self);
  // Size the control to A4 at screen DPI so pagination matches the DC
  RichEdit1.Width  := Round(ScaleX(210, mmPixel));
  RichEdit1.Height := Round(ScaleY(297, mmPixel));
  RichEdit1.Lines.LoadFromFile(
    ExtractFilePath(Application.ExeName) + 'document.rtf');
end;

procedure TForm1.Button1Click(Sender: TObject);
var
  Dc: HDC;
  PageNumber, LastChar, PdfDocId: Integer;
begin
  PageNumber := 1;
  LastChar   := 0;
  repeat
    // Obtain a virtual DC sized to A4
    Dc := PdfDoc.GetCanvasDC(
      Round(ScaleX(210, mmPixel)),
      Round(ScaleY(297, mmPixel)));
    // Render the next page of RTF content into the DC
    LastChar := PrintRtfBox(Dc, RichEdit1, LastChar);
    // Capture the DC contents as a PDF document
    PdfDoc.LoadFromCanvasDc(96, 0);
    PdfDocId := PdfDoc.SelectedPdfDocument;
    PdfDoc.SaveToFile(
      ExtractFilePath(Application.ExeName)
      + 'Output' + IntToStr(PageNumber) + '.pdf');
    PdfDoc.RemovePdfDocument(PdfDocId);
    Inc(PageNumber);
  until LastChar = 0;
end;

function TForm1.PrintRtfBox(hDc: HDC; rtfBox: TRichEdit;
  FirstChar: Integer): Integer;
var
  RcDrawTo, RcPage: TRect;
  Fr: TFormatRange;
  NextCharPosition: Integer;
begin
  RcPage.Left   := 0;
  RcPage.Top    := 0;
  RcPage.Right  := rtfBox.Left + rtfBox.Width  + 100;
  RcPage.Bottom := rtfBox.Top  + rtfBox.Height + 100;

  RcDrawTo.Left   := rtfBox.Left;
  RcDrawTo.Top    := rtfBox.Top;
  RcDrawTo.Right  := rtfBox.Left + rtfBox.Width;
  RcDrawTo.Bottom := rtfBox.Top  + rtfBox.Height;

  Fr.hdc         := hDc;
  Fr.hdcTarget   := hDc;
  Fr.rc          := RcDrawTo;
  Fr.rcPage      := RcPage;
  Fr.chrg.cpMin  := FirstChar;
  Fr.chrg.cpMax  := -1;

  NextCharPosition :=
    SendMessage(rtfBox.Handle, EM_FORMATRANGE, 1, LPARAM(@Fr));
  if NextCharPosition < Length(rtfBox.Text) then
    Result := NextCharPosition
  else
    Result := 0;  // signals last page
end;

end.

Что делает цикл

PrintRtfBox заполняет структуру TFormatRange и передает ее элементу управления Rich Edit через SendMessage. Элемент управления рендерит символы, начиная с cpMin, останавливаясь при заполнении DC, и возвращает позицию первого символа, который не поместился. Когда возвращаемое значение равно или превышает общую длину текста, каждый символ был отрендерен, и функция возвращает ноль, что завершает цикл repeat...until

Каждая итерация создает один PDF-файл с именами Output1.pdf, Output2.pdf и так далее. Если вместо этого вы хотите получить единый многостраничный документ, API добавления страниц библиотеки позволяет собрать их постфактум, или вы можете перестроить цикл для вызова AddPage в рамках одного сеанса документа. Паттерн SaveToFile с последующим RemovePdfDocument на каждой итерации, показанный выше, удерживает пиковое потребление памяти в пределах объема содержимого одной страницы, что имеет значение для очень длинных файлов RTF

Детали изменения размеров, которые сбивают с толку

Аргумент 96 DPI в LoadFromCanvasDc сообщает библиотеке, при каком разрешении экрана был отрендерен DC, чтобы она могла рассчитать правильное преобразование точек в пиксели для страницы PDF. Ошибитесь в этом, и текст в выводе появится в неправильном размере, даже если изображение на экране выглядит правильно

Добавление +100 к RcPage.Right и RcPage.Bottom — это небольшое поле за видимым краем элемента управления. Rich Edit использует прямоугольник rcPage, чтобы решить, где разделять страницы; без поля строка, которая попадает точно на границу, может быть продублирована на двух страницах. Это не магическая константа: вы хотите, чтобы она была достаточно большой, чтобы граница страницы четко попадала внутрь области компоновки элемента управления, а не на последний пиксель

Наконец, элемент управления должен быть уже прикреплен к видимому окну формы при выполнении FormCreate, чтобы его дескриптор окна был действительным до первого вызова SendMessage. Динамически созданный во время выполнения TRichEdit требует явного вызова HandleNeeded до начала цикла рендеринга, если форма еще не была показана

Работа со шрифтами и функциями RTF

Поскольку рендеринг выполняется движком Windows Rich Edit, подстановка шрифтов следует тем же правилам, которые он использует для отображения и печати. Шрифты, указанные в файле RTF и установленные на компьютере, будут отрендерены точно; отсутствующие шрифты будут заменены без уведомления, что может привести к изменению длины строк и разбиения на страницы. При производственном пакетном преобразовании это стоит протестировать отдельно: загрузите документ с каждым шрифтом, который используют ваши источники RTF, и убедитесь, что количество страниц на выходе совпадает с тем, что вы ожидаете от ручного предварительного просмотра

Таблицы, встроенные изображения и большинство функций форматирования Rich Text работают без дополнительной обработки, поскольку Rich Edit рендерит их изначально. Единственная область, которая может вызывать удивление, — это текст, использующий настраиваемый межстрочный интервал или отступы первой строки, выраженные в твипах: внутренняя система координат Rich Edit работает в твипах (1/1440 дюйма), тогда как координаты DC, которые вы задаете в TFormatRange, указаны в пикселях при текущем DPI. Элемент управления выполняет преобразование внутренне, но если вы формируете RTF программно, вам следует убедиться, что ваши значения полей указаны в правильных единицах измерения

Учет DPI и дисплеи с высоким DPI

На дисплее с масштабированием 150% (144 DPI) вызов ScaleX(210, mmPixel) вернет большее количество пикселей, чем на дисплее с 100%. Библиотека PDF записывает любые пиксельные размеры, которые вы передаете в GetCanvasDC, и использует аргумент DPI в LoadFromCanvasDc для обратного расчета физического размера страницы в PDF. Если переданное вами значение DPI совпадает с DPI, при котором работает ваше приложение, размер страницы на выходе будет правильным независимо от масштабирования дисплея

Если ваше приложение не учитывает DPI (прежнее поведение по умолчанию), Windows масштабирует экранный DC, и ваши расчеты пикселей будут неверными на машинах с высоким DPI. Самое простое решение — объявить поддержку DPI в манифесте приложения; тогда приложение получает реальные пиксели устройства, и 96, которые вы передаете в LoadFromCanvasDc, следует заменить на фактический DPI дисплея, полученный из GetDeviceCaps(GetDC(0), LOGPIXELSX). В приведенном выше примере кода жестко задано 96, поскольку это подходит для среды с масштабированием 100% и позволяет сохранить пример коротким

Структура вывода: один файл на страницу против объединенного документа

Приведенный выше цикл записывает каждую страницу в отдельный файл PDF. Зависит ли это от того, что вам нужно, определяется последующим использованием. Системы генерации отчетов часто нуждаются в отдельных страницах, поскольку они собирают окончательный документ позже путем объединения или перестановки страниц. Если вы хотите получить единый PDF с самого начала, библиотека позволяет создать документ с несколькими страницами за один сеанс: создайте документ один раз вне цикла, вызывайте метод добавления страницы вместо SaveToFile внутри цикла, и сохраните полный документ после выхода из цикла. Это позволяет избежать промежуточных файлов и является правильной структурой для большинства сценариев преобразования в один документ

Для больших файлов RTF стоит добавить обратную связь о прогрессе в цикле, так как скорость преобразования примерно пропорциональна количеству страниц, и документ на 200 страниц может занять несколько секунд. Структуру repeat...until легко расширить: отслеживайте смещение символа в обновлении индикатора прогресса после каждой итерации, используя значение LastChar, деленное на общее количество символов из RichEdit1.GetTextLen

Показанные здесь методы GetCanvasDC и LoadFromCanvasDc являются частью библиотеки losLab PDF для Delphi и C++Builder