Недавно в одном стартапе я решал задачу генерации билетов в формате 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.

Выводы


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

Комментарии (25)


  1. thatsme
    13.08.2019 22:48
    +1

    wkhtmltopdf.org Должен быть попроще. Или нет?


    1. SemenPV
      13.08.2019 22:53

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

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

      Кстати, некоторые коммерческие библиотеки для генерации PDF используют подобный подход, но с IE.


    1. Max_vst Автор
      13.08.2019 23:09

      Wkhtmltopdf использует тот же движок хрома, только при этом его версия обновляется не так часто как хотелось бы. На момент изучения вопроса wkhtmltopdf не поддерживал все CSS свойства flexbox'ов и совсем не умел работать с grid'ами.


      1. VolCh
        13.08.2019 23:45

        А с JS он работает? Предлагаемых способом можно хоть SPA страницы печатать.


        1. Max_vst Автор
          14.08.2019 10:36

          Да, Javascript, который должен запуститься во время загрузки страницы отработает. А в SPA перед печатью в PDF Selenium может ещё различные формы заполнить и на нужные кнопочки нажать.


    1. darkdaskin
      14.08.2019 17:20

      Использованный в этой библиотеке движок имеет кучу багов с отображением страниц, которые не чинятся годами. Всё особенно плохо при печати многостраничных таблиц. Зато, в отличие от хрома, работает в ограниченных окружениях, например внутри Azure App Service.


  1. alex103
    14.08.2019 07:18

    chrome --headless --disable-gpu --print-to-pdf google.com

    по моему ничего не происходит…
    по крайней мере обнаружить output.pdf не удалось… (


    1. VolCh
      14.08.2019 09:25

      А где ищите?


      ~/$cd tett/
      
      ~/tett$google-chrome --headless --disable-gpu --print-to-pdf google.com
      
      [0814/092158.306472:INFO:headless_shell.cc(572)] Written to file output.pdf.
      [0814/092158.313610:ERROR:browser_process_sub_thread.cc(203)] Waited 3 ms for network service
      
      :~/tett$ ls
      
      output.pdf


    1. Alex_X_S
      14.08.2019 10:24

      Аналогично, но разработчики также рекомендуют использовать:
      developers.google.com/web/updates/2017/04/headless-chrome


    1. Alex_X_S
      14.08.2019 10:26

      После --print-to-pdf надо добавить =имя файла и все.
      --print-to-pdf=out.pdf


      1. alex103
        14.08.2019 11:35

        Да! Добавление имени файла помогает!
        Благодарю!
        (Windows, такой виндовс)
        ))


    1. Max_vst Автор
      14.08.2019 10:30

      Файл output.pdf должен появиться в той директории, в которой был запущен chrome.
      Покажите вывод командной строки, код возврата вызова Chrome (в линуксе сразу после вызова chrome запустите эту команду `echo $?`) и содержимое директории в которой запускался Chrome.

      chrome --headless --disable-gpu --print-to-pdf google.com
      echo $?
      ls -l
      


  1. VolCh
    14.08.2019 11:55
    +1

    Главный недостаток при рендеринге браузерами пдф: они не поддерживают CSS свойства для хедеров, футеров и т. п. При работе с хромом по chrome devtools protocol (наиболее известная имплементация -puppeteer) есть ограниченная возможность управлять ими, в частности нумерация страниц, какие-то статические (или из title документа) повторяющиеся (для хедера первой страницы можно хак сделать, чтобы убрать или сделать специфичный) хедеры/футеры в том числе с логотипами (получилось только c base64 url). Чем-то напоминает вёрстку писем попытка их сверстать.


    Кстати, page { size: A4;} нормально в хроме работает только как глобальный стиль, селекторы типа left/right сводят его с ума :)


  1. Isiirk
    14.08.2019 12:51

    Неожиданное применение… другая сфера ИТ, но точно где нибудь рассмотрю как вариант, спасибо!


  1. XelaNimed
    14.08.2019 13:34

    Прошу прощения, но не проще ли использовать Puppeteer? Можно сразу указать настройки печати без «костылей» с CSS.


    1. VolCh
      14.08.2019 15:51

      Это то же самое, насколько я вижу


    1. Max_vst Автор
      14.08.2019 20:12

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


      1. VolCh
        14.08.2019 22:36

        Настройки CSS и этого вызова не конфликтуют друг с другом, а дополняют.


  1. SmithZx
    14.08.2019 18:00

    Есть ли возможность вызвать преобразование из UI и отправить результат через XHR?
    www.npmjs.com/package/xml2js по определённым причинам не подходит.


  1. frankmasonus
    15.08.2019 14:53

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


    1. Max_vst Автор
      15.08.2019 15:40

      Сообщите подробности: какие версии OC и Chrome, как запускали, какие были дополнительные настройки?


      1. frankmasonus
        15.08.2019 17:16

        пардон, проблема была не с хромом.


  1. eugeneb0
    15.08.2019 23:57
    +1

    Прелестно!

    Напечатал себе Вашу статью в PDF описанным методом. В качестве теста, ну и чтобы копия была. Спасибо!


  1. misato
    16.08.2019 09:27
    +1

    Мне кажется (хоть мой английский и не настолько хорош, чтобы утверждать это наверняка), что лицензия Chrome не разрешает использование его как части собственного программного продукта (п. 9.2. EULA www.google.com/intl/en_sg/chrome/privacy/eula_text.html, а также в п.3 дополнительной части, касающейся прав Adobe в этом же документе).

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

    В общем, есть пара принципиальных проблем.


    1. Max_vst Автор
      16.08.2019 12:05

      Спасибо за уточнение по поводу лицензии. Взамен Chrome можно взять Chromium, его разные компоненты распространяются под лицензиями BSD, MIT, *LGPL, MS-PL, MPL+GPL+LGPL, tri-licensed. Изучение этого вопроса потянет на отдельную статью :)