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

Рендеринг таблиці даних у PDF в Delphi з HotPDF

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

Наступний приклад виводить записи клієнтів, але код малювання не знає і не турбується про те, звідки беруться рядки. В оригіналі використовувався застарілий TTable; запит FireDAC, набір даних у пам'яті або звичайний масив записів передаються в ті самі підпрограми без змін. Важливо те, що ви можете проходити дані по одному рядку за раз і читати з кожного чотири рядкові поля. Тримайте рендеринг окремо від джерела даних, і ви зможете змінювати будь-яку сторону, не порушуючи іншу

Геометрія стовпців на першому місці

Перш ніж буде намальований хоча б один символ, вирішіть, де знаходиться кожен стовпець. Тут таблиця має чотири стовпці, тому їй потрібні чотири ліві краї та відоме праве поле. Жорстке кодування магічного числа при кожному виклику TextOut, як це часто робиться у швидких прикладах, є саме тим, що робить таблицю складною для розширення пізніше. Назвіть краї один раз, у пунктах від початку координат у нижньому лівому куті, і кожен виклик малювання посилатиметься на них за назвою:

Геометрія колонок таблиці HotPDF у Delphi: іменовані межі x на 70, 110, 300 і 480 пунктах між лініями рамки на 50 і 570 пунктах
Чотири названі ліві краї й відоме праве поле фіксують усю геометрію таблиці до першого виклику TextOut
const
  ColNo   = 70;    // лівий край стовпця «No.»
  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;    // вертикальна відстань між базовими лініями

procedure PrintRow(Page: THPDFPage; Y: Single;
  const ANo, AName, AAddr, ACity: string; Shaded: boolean);
begin
  if Shaded then
  begin
    // Затінена смуга за рядком. Rectangle приймає 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 може взяти найдовше очікуване значення в кожному стовпці, виміряти його один раз при вибраному розмірі шрифту та отримати ліві краї з цих ширин плюс інтервал. Структура підпрограми не змінюється; змінюється лише джерело констант

Заголовок, лінії та одне місце, яке ними керує

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

HotPDF перемальовує заголовки та лінії DrawHeader на першій сторінці і знову після кожного AddPage, тож обидві сторінки PDF відкриваються з ідентичним заголовком
Заголовна підпрограма знову запускається на кожній новій сторінці, тож назви й лінії осідають на тому самому місці за побудовою
procedure DrawHeader(Page: THPDFPage; var Y: Single; PageNo: Integer);
begin
  // Ліворуч: мітка джерела й номер сторінки. Праворуч: час генерації
  Page.SetFont('Arial', [fsItalic], 10);
  Page.TextOut(RowLeft, Y, 0, 'customer.db   Page ' + IntToStr(PageNo));
  Page.TextOut(ColCity, Y, 0, DateTimeToStr(Now));

  // Дві горизонтальні лінії, що обрамляють заголовки стовпців
  Page.MoveTo(RowLeft, Y + 15);
  Page.LineTo(RowRight, Y + 15);
  Page.MoveTo(RowLeft, Y + 45);
  Page.LineTo(RowRight, Y + 45);
  Page.Stroke;

  // Заголовки стовпців напівжирним шрифтом, щоб вони читалися як заголовки
  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;  // перейти за обрамлений заголовок перед першим рядком основних даних
end;

Зверніть увагу, що DrawHeader приймає Y за посиланням і пересуває його вперед. Тому, хто викликає, ніколи не потрібно пам'ятати, наскільки високим є заголовок; підпрограма, яка його малює, є тією, яка це знає. Це правило єдиного володіння - це те, що утримує макет від зсуву, коли ви пізніше додаєте логотип або зведення фільтра до смуги заголовка. Цикл тіла залишається незмінним. Він просто продовжує малювати рядки з того місця, куди зараз вказує Y

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

Цикл курсору керує розривом сторінки

Малювання - це легка частина. Частина, яка відрізняє іграшку від звіту, - це розбиття на сторінки: знання перед тим, як ви намалюєте рядок, чи він ще поміщається, і початок нової сторінки з новим заголовком, коли це не так. Це рішення належить лише одному місцю - циклу, який проходить по даних, і більше ніде

Блок-схема курсорного циклу Delphi: Y нижче 60 запускає AddPage, повторне читання CurrentPage, повторний виклик SetFont і повторення заголовка перед наступним рядком таблиці
Курсорний цикл — єдине місце, що відкриває свіжу сторінку та перевстановлює її, коли Y падає нижче нижнього поля
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;

    // Заголовок звіту один раз, угорі першої сторінки
    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
      // Не вистачає місця? Відкрити нову сторінку й повторити там заголовок
      if Y < 60 then
      begin
        Pdf.AddPage;
        Page := Pdf.CurrentPage;   // AddPage переміщує CurrentPage уперед
        Inc(PageNo);
        Y := 760;
        DrawHeader(Page, Y, PageNo);
      end;

      Shaded := not Shaded;
      Page.SetFont('Arial', [], 10);   // SetFont потрібно повторно викликати на кожній новій сторінці
      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 безпосередньо в TextOut може з'явитися як буквальний текст Null або як пробіл, залежно від того, як ви його конвертуєте. Вибирайте рендеринг свідомо, а не дозволяйте конвертації варіантів обирати за вас

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

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

Наведені вище лінії та затінені смуги спираються на ті ж векторні та колірні примітиви, що розглядаються в посібнику з малювання на полотні, якщо ви хочете спочатку розглянути виклики Rectangle, MoveTo та LineTo окремо. Використані тут примітиви малювання є частиною HotPDF Delphi Component для Delphi та C++Builder