Недавно в одном стартапе я решал задачу генерации билетов в формате PDF. На тот момент уже был готов сайт с устоявшимся стеком технологий, поэтому я искал подход, который бы не потребовал использования дополнительных инструментов. В итоге я предложил сперва создавать билеты в формате HTML, а затем конвертировать в PDF с помощью браузера Chrome. Как оказалось, данным способом можно генерировать не только билеты, богато декорированные CSS, но и самые разные отчеты с графиками на JavaScript. В этой статье я расскажу о том, как для этих целей запустить Chrome, дам несколько советов по настройке CSS, а так же обсужу недостатки данного решения.


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


Почему выбран именно этот вариант?


Самым г��авным преимуществом является то, что для генерации PDF браузером Chrome не нужно расширять технологический стек. Фронтенд разработчики создают HTML привычными средствами разработки и сразу видят промежуточные результаты труда в браузере. В это же время Chrome уже наверняка крутится в тестах и перенести его на бекенд не составляет большого труда. Так же следует отметить тот факт, что верстальщику становится доступен весь арсенал css свойств включая Flexbox и Grid.
О недостатках и способах их обхода я расскажу по ходу статьи.


Решаем задачу одной строкой


В командной строке вызываем Chrome в безголовом режиме с сохранением страницы в pdf:


chrome --headless --disable-gpu --print-to-pdf https://google.com

Пользователям Linux может понадобиться вместо chrome запускать chromium-browser.
Пользователям MAC может быть полезно предварительно создать alias:


alias chrome="/Applications/Google\\ \\Chrome.app/Contents/MacOS/Google\\ \\Chrome"

UPDATE: В комментариях внесли уточнение, что пользователям Windows необходимо явно задавать имя PDF файла --print-to-pdf=output.pdf


Если у Вас уже есть генератор HTML документов, вместо https://google.com укажите URL для получения этого документа.


Открываем в локальной директории файл output.pdf и смотрим результат.
Первое, что может броситься в глаза — это наличие Header с датой печати и Footer с URL и нумерацией страниц. Для того, чтобы их убрать нужно добавить несколько CSS правил. Эти правила вряд ли получится добавить на страницу google.com, поэтому для дальнейшей работы лучше создать собственный HTML докуме��т.


Добавляем CSS


В CSS есть специальный медиазапрос @page, который применяется для печати, зададим в нем нулевые отступы так, чтобы Header и Footer просто не помещались:


@page {
    size: A4;
    margin: 0mm;
}

Этот способ сработает только для одностраничных документов, при печати двух и более страниц на последней внизу останется Footer с URL и нумерацией страниц. Можно явно попросить Chrome отключить отображение Header и Footer, задав параметр печати displayHeaderFooter = False, но на данный момент он не вынесен в интерфейс командной строки. Чтобы добраться до него, понадобятся инструменты для автоматизации работы с браузером: Selenium или puppeteer. Дальше я рассмотрю первый вариант, потому как в моем проекте использовался Python.


Запускаем Chrome через Selenium


Итак, устанавливаем Selenium командой pip install selenium, скачиваем с http://chromedriver.chromium.org/ хромдрайвер, соответствующий Вашей версии Chrome и используем функцию get_pdf_from_html из примера ниже:


import sys
from selenium import webdriver
from selenium.webdriver.chrome.options import Options
import json, base64

def get_pdf_from_html(path, chromedriver='./chromedriver', print_options = {}):
  # запускаем Chrome
  webdriver_options = Options()
  webdriver_options.add_argument('--headless')
  webdriver_options.add_argument('--disable-gpu')
  driver = webdriver.Chrome(chromedriver, options=webdriver_options)

  # открываем заданный url
  driver.get(path)

  # задаем параметры печати
  calculated_print_options = {
    'landscape': False,
    'displayHeaderFooter': False,
    'printBackground': True,
    'preferCSSPageSize': True,
  }
  calculated_print_options.update(print_options)

  # запускаем печать в pdf файл
  result = send_devtools(driver, "Page.printToPDF", calculated_print_options)
  driver.quit()
  # ответ приходит в base64 - декодируем
  return base64.b64decode(result['data'])

def send_devtools(driver, cmd, params={}):
  resource = "/session/%s/chromium/send_command_and_get_result" % driver.session_id
  url = driver.command_executor._url + resource
  body = json.dumps({'cmd': cmd, 'params': params})
  response = driver.command_executor._request('POST', url, body)
  if response['status']:
    raise Exception(response.get('value'))
  return response.get('value')

if __name__ == "__main__":
  if len(sys.argv) != 3:
    print ("usage: converter.py <html_page_sourse> <filename_to_save>")
    exit()

  result = get_pdf_from_html(sys.argv[1])
  with open(sys.argv[2], 'wb') as file:
    file.write(result)

Для получения PDF файла можно запустить этот пример из командной строки указав url и имя файла для сохранения PDF, либо вызвать функцию get_pdf_from_html и передать ей три аргумента:


  1. path — url html документа;
  2. chromedriver — путь на локальной машине к хромдрайверу (по умолчанию должен лежать в локальной директории);
  3. print_options — дополнительные атрибуты печати.

Следует отметить, что Selenium не имеет стандартного интерфейса для печати страницы в PDF, к тому же это умеет делать только Chrome, поэтому приходится напрямую вызывать driver.command_executor._request.


Теперь разберемся, какие средства доступны для контроля размещения контента на многостраничных документах.


Типографика в CSS


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


@page :left {
    margin-left: 4cm;
    margin-right: 2cm;
}

@page :right {
    margin-left: 4cm;
    margin-right: 2cm;
}

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


@page :first {
    margin-top: 10cm    /* Top margin on first page 10cm */
}

Есть возможность установить разрыв страницы перед заголовком первого уровня так, чтобы он начинался на нечетной странице:


h1 { page-break-before : right }

Посредством свойства page-break-after можно запретить разрыв страницы сразу после некоторого элемента, например, заголовка второго уровня:


h2 { page-break-after : avoid }

Свойство page-break-inside поможет избежать разрыва страниц там, где делать это нежелательно, например посреди таблицы


table { page-break-inside : avoid }

Свойства orphans и orphans помогут избежать разрыва страниц в начале и в конце абзаца:


@page {
    orphans:4;
    widows:2;
}

Что с производительностью?


На Core i5-8600K 3600MHz в один поток одно преобразование простого документа выполняется за 0.6 сек. На моей портативной печатной машинке конца 2013 года 2.4 Ггц — 1.5 секунды.
Очевидно, что основные ресурсы тратятся на запуск браузера. Можно сократить время преобразования большого количества файлов, если запустить Chrome один раз как микросервис и отправлять ему URL для преобразования. Реализация этого способа выходит за рамки данной статьи.


Что еще не так?


Я вижу две основные проблемы:


  1. Невозможность простого определения положения элементов в документе. Это делает затруднительным формирование оглавления с автоматическим указанием номеров страниц, особенно, если размер контента заранее неизвестен.
  2. Преобразованием занимается Chrome — продукт Google который собирает о пользователях самую разную информацию. Если утечка данных из документа недопустима, к предлагаемому решению нужно относится осторожно — закрыть браузеру выход на внешние ресурсы, или вовсе поискать другое решение. Использование Chromium с открытыми исходниками не решает проблемы — в нем уже находили жучки от Google.

Выводы


Выводы о допустимости использования такого подхода предлагаю сделать самостоятельно. Каждый проект уникален по своему. Подойдет ли этот способ в Вашем проекте, решать Вам.