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

Рендеринг таблиці даних у 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 безпосередньо в TextOut може з'явитися як буквальний текст Null або як пробіл, залежно від того, як ви його конвертуєте. Вибирайте рендеринг свідомо, а не дозволяйте конвертації варіантів обирати за вас

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

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

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