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

Рендеринг таблицы данных в PDF в Delphi с помощью HotPDF

Набор данных — это строки и столбцы; страница PDF — это пустая координатная сетка, в которой нет понятия ни того, ни другого. Преодоление этого разрыва и есть вся работа здесь. В HotPDF нет вызова DrawTable, который берет набор данных и дает вам отформатированную сетку. Вместо этого вы получаете примитивы, из которых состоит сетка: TextOut для размещения строки в точке, SetFont для выбора шрифта, Rectangle и Fill для затенения полосы, и MoveTo / LineTo / Stroke для рисования линий. Работающий экспортер таблиц — это дисциплина превращения мышления строками и столбцами в явные координаты x и y, а затем сохранения этих координат честными, когда данные выходят за нижний край страницы

В следующем примере выводятся записи клиентов, но ничего в коде отрисовки не знает и не заботится о том, откуда берутся строки. В оригинале использовалась устаревшая таблица TTable; запрос FireDAC, набор данных в памяти или простой массив записей питают одни и те же процедуры без изменений. Важно то, что вы можете проходить данные по одной строке за раз и считывать из каждой по четыре строковых поля. Держите рендеринг отдельно от источника данных, и вы сможете изменять любую из сторон, не нарушая работу другой

Геометрия столбцов на первом месте

Прежде чем будет нарисован хоть один символ, решите, где будет располагаться каждый столбец. Здесь таблица имеет четыре столбца, поэтому ей нужны четыре левых края и известное правое поле. Жесткое кодирование магического числа при каждом вызове TextOut, как это обычно бывает в быстрых примерах — это именно то, что делает таблицу болезненной для расширения позже. Назовите края один раз в пунктах от начала координат в левом нижнем углу, и каждый вызов отрисовки будет ссылаться на них по имени:

const
  ColNo   = 70;    // left edge of the "No." column
  ColName = 110;   // company name
  ColAddr = 300;   // street address
  ColCity = 480;   // city
  RowLeft = 50;    // table frame: left rule
  RowRight = 570;  // table frame: right rule
  RowStep = 20;    // vertical distance between baselines

procedure PrintRow(Page: THPDFPage; Y: Single;
  const ANo, AName, AAddr, ACity: string; Shaded: boolean);
begin
  if Shaded then
  begin
    // A shaded band behind the row. Rectangle takes X, Y, Width, Height.
    Page.SetRGBFillColor($00FFF3DD);
    Page.Rectangle(RowLeft, Y - 4, RowRight - RowLeft, RowStep);
    Page.Fill;
    Page.SetRGBFillColor(clBlack);
  end;
  Page.TextOut(ColNo,   Y, 0, ANo);
  Page.TextOut(ColName, Y, 0, AName);
  Page.TextOut(ColAddr, Y, 0, AAddr);
  Page.TextOut(ColCity, Y, 0, ACity);
end;

Здесь две детали отрабатывают свою стоимость. Заштрихованная полоса рисуется первой, а затем текст сверху, потому что порядок рисования — это z-порядок в PDF: заполните прямоугольник после текста, и вы похороните строку. И чередующийся оттенок — это не украшение ради самого себя. В плотном отчете это самый дешевый способ не дать глазу соскользнуть на неправильную строку, поэтому цикл позже переключает логическое значение в каждой строке и передает его прямо в Shaded

Позиции столбцов выше фиксированы, что честно для отчета, схему которого вы контролируете. Когда данные переменные, измеряйте, а не угадывайте. HotPDF предоставляет возможность измерения ширины текста для объекта страницы, поэтому производственная версия PrintRow может взять самое длинное ожидаемое значение в каждом столбце, измерить его один раз при выбранном размере шрифта и вывести левые края из этих значений ширины плюс интервал. Форма подпрограммы не меняется; меняется только источник констант

Заголовок, линии и одно место, которое ими владеет

Таблица, которая прокручивается за пределы страницы и продолжается на следующей без меток столбцов, нечитаема. Исправление заключается в том, чтобы относиться к заголовку как к чему-то, что вы перерисовываете, а не как к чему-то, что вы рисуете один раз. Поместите заголовки столбцов и горизонтальные линии, которые их обрамляют, в одну процедуру и вызывайте эту процедуру как в начале, так и каждый раз, когда вы открываете новую страницу. Поскольку заголовок и тело используют одни и те же константы столбцов, они выстраиваются в ряд по построению

procedure DrawHeader(Page: THPDFPage; var Y: Single; PageNo: Integer);
begin
  // Left: source label and page number. Right: generation time.
  Page.SetFont('Arial', [fsItalic], 10);
  Page.TextOut(RowLeft, Y, 0, 'customer.db   Page ' + IntToStr(PageNo));
  Page.TextOut(ColCity, Y, 0, DateTimeToStr(Now));

  // Two horizontal rules that box the column titles.
  Page.MoveTo(RowLeft, Y + 15);
  Page.LineTo(RowRight, Y + 15);
  Page.MoveTo(RowLeft, Y + 45);
  Page.LineTo(RowRight, Y + 45);
  Page.Stroke;

  // The column titles, in a heavier face so they read as headings.
  Page.SetFont('Times New Roman', [fsBold], 12);
  Page.SetRGBFillColor(clNavy);
  PrintRow(Page, Y + 25, 'No.', 'Company', 'Address', 'City', False);
  Page.SetRGBFillColor(clBlack);

  Y := Y + RowStep + 45;  // advance past the boxed header before the first body row
end;

Обратите внимание, что DrawHeader принимает Y по ссылке и перемещает его вперед. Вызывающему никогда не нужно помнить, какой высоты заголовок; подпрограмма, которая его рисует — это подпрограмма, которая это знает. Это единое правило владения — это то, что удерживает макет от смещения, когда вы позже добавите логотип или сводку фильтров в полосу заголовка. Основной цикл остается в неведении. Он просто продолжает рисовать строки оттуда, куда в данный момент указывает Y

Сами линии — это разница между списком и таблицей. Вертикальные разделители столбцов — это та же идея, примененная к оси x: MoveTo / LineTo / Stroke на каждом крае столбца, идущем от верхней линии к нижней части последней строки на странице. Образец придерживается горизонтальных линий, чтобы оставаться читабельным, но производственный этап является механическим после того, как существуют константы столбцов

Цикл курсора владеет разрывом страницы

Отрисовка — это легкая половина. Половина, которая отделяет игрушку от отчета — это разбиение на страницы: знание перед тем, как нарисовать строку, помещается ли она еще, и запуск новой страницы с новым заголовком, когда это не так. Это решение принимается ровно в одном месте, в цикле, который обходит данные, и больше нигде

var
  Pdf: THotPDF;
  Page: THPDFPage;
  Y: Single;
  PageNo: Integer;
  Shaded: boolean;
begin
  Pdf := THotPDF.Create(nil);
  try
    Pdf.FileName := 'CustomerReport.pdf';
    Pdf.BeginDoc;
    Page := Pdf.CurrentPage;

    // Report title, once, at the top of the first page.
    Page.SetFont('Arial', [fsBold], 24);
    Page.TextOut(200, 800, 0, 'Customer Report');

    PageNo := 1;
    Y := 760;
    DrawHeader(Page, Y, PageNo);
    Shaded := False;

    CustomerTable.First;
    while not CustomerTable.Eof do
    begin
      // Out of room? Open a new page and repeat the header there.
      if Y < 60 then
      begin
        Pdf.AddPage;
        Page := Pdf.CurrentPage;   // AddPage moves CurrentPage forward
        Inc(PageNo);
        Y := 760;
        DrawHeader(Page, Y, PageNo);
      end;

      Shaded := not Shaded;
      Page.SetFont('Arial', [], 10);   // SetFont must be reissued on every new page
      PrintRow(Page, Y,
        VarToStr(CustomerTable['CustNo']),
        VarToStr(CustomerTable['Company']),
        VarToStr(CustomerTable['Addr1']),
        VarToStr(CustomerTable['City']),
        Shaded);

      Y := Y - RowStep;
      CustomerTable.Next;
    end;

    Pdf.EndDoc;
  finally
    Pdf.Free;
  end;
end;

Весь цикл приводится в движение двумя фактами о координатах. PDF измеряет y вверх от левого нижнего угла, поэтому строки идут вниз по странице, каждый раз вычитая RowStep из Y, а тест на заполнение страницы срабатывает, когда Y опускается ниже нижнего поля, а не выше какой-то вершины. Перепутайте направление, и ваша первая строка напечатается за нижним краем, в то время как цикл будет думать, что у него есть целая страница свободного места

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

Случаи, которые ломают экспортер таблиц

Большинство ошибок таблиц не проявляются на счастливом пути из нескольких десятков аккуратных строк. Они живут на краях, а края дешево тестировать, если вы знаете, где они находятся

  • Пустые наборы данных. Цикл по нулю строк создает страницу с заголовком и ничем под ним, что, по крайней мере, выглядит преднамеренным. Пустая страница без заголовка выглядит как сбой. Решите, чего вы хотите, перед выпуском
  • Строка, которая попадает точно на границу. Создайте отчет, последняя строка которого находится на один шаг выше поля, а затем отчет, следующая строка которого находится на один шаг ниже него. Ошибка на единицу при разбиении на страницы скрывается до тех пор, пока данные не окажутся точно неправильной длины
  • Слишком длинные значения. Название компании, ширина которого превышает ширину ее столбца, будет переходить на следующий. Измерьте поле и определите политику: перенос на вторую строку, отсечение или усечение с многоточием. Молчание — это не политика
  • Поля Null. Чтение значения null прямо в TextOut может всплыть как буквальный текст Null или как пустое место в зависимости от того, как вы его конвертируете. Выбирайте рендеринг осознанно, а не позволяйте конвертации варианта выбирать за вас

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

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

Описанные выше линии и заштрихованные полосы опираются на те же векторные и цветовые примитивы, которые рассматриваются в руководстве по рисованию на холсте, если вы хотите сначала рассмотреть вызовы Rectangle, MoveTo и LineTo. Примитивы рисования, используемые здесь, являются частью компонента HotPDF для Delphi и C++Builder