Разбираем возможности конвертирования HTML в PDF браузером Google Chrome


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

    Выводы


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

    Поделиться публикацией
    AdBlock похитил этот баннер, но баннеры не зубы — отрастут

    Подробнее
    Реклама

    Комментарии 28

      +1
      wkhtmltopdf.org Должен быть попроще. Или нет?
      • НЛО прилетело и опубликовало эту надпись здесь
          0
          Wkhtmltopdf использует тот же движок хрома, только при этом его версия обновляется не так часто как хотелось бы. На момент изучения вопроса wkhtmltopdf не поддерживал все CSS свойства flexbox'ов и совсем не умел работать с grid'ами.
            0

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

              0
              Да, Javascript, который должен запуститься во время загрузки страницы отработает. А в SPA перед печатью в PDF Selenium может ещё различные формы заполнить и на нужные кнопочки нажать.
            0
            Использованный в этой библиотеке движок имеет кучу багов с отображением страниц, которые не чинятся годами. Всё особенно плохо при печати многостраничных таблиц. Зато, в отличие от хрома, работает в ограниченных окружениях, например внутри Azure App Service.
            0
            chrome --headless --disable-gpu --print-to-pdf google.com

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

              А где ищите?


              ~/$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
                0
                Аналогично, но разработчики также рекомендуют использовать:
                developers.google.com/web/updates/2017/04/headless-chrome
                  0

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

                    0
                    Да! Добавление имени файла помогает!
                    Благодарю!
                    (Windows, такой виндовс)
                    ))
                    0
                    Файл output.pdf должен появиться в той директории, в которой был запущен chrome.
                    Покажите вывод командной строки, код возврата вызова Chrome (в линуксе сразу после вызова chrome запустите эту команду `echo $?`) и содержимое директории в которой запускался Chrome.
                    chrome --headless --disable-gpu --print-to-pdf google.com
                    echo $?
                    ls -l
                    
                    +1

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


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

                      0
                      Неожиданное применение… другая сфера ИТ, но точно где нибудь рассмотрю как вариант, спасибо!
                        0
                        Прошу прощения, но не проще ли использовать Puppeteer? Можно сразу указать настройки печати без «костылей» с CSS.
                          0

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

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

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

                            0
                            Есть ли возможность вызвать преобразование из UI и отправить результат через XHR?
                            www.npmjs.com/package/xml2js по определённым причинам не подходит.
                              0
                              Так и не смог безголового хрома сохранять с цветом — только монохром. Ну и вообще, с бэкграундами проблемы.
                                0
                                Сообщите подробности: какие версии OC и Chrome, как запускали, какие были дополнительные настройки?
                                  0
                                  пардон, проблема была не с хромом.
                                +1
                                Прелестно!

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

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

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

                                      Про это? Тут вопрос, является ли подобная либа/сервис производным или составным продуктом или не является. Имхо, скорее нет, чем да.

                                      0
                                      Использование Chromium с открытыми исходниками не решает проблемы — в нем уже находили жучки от Google.

                                      Можно пруфы?
                                        0
                                        Несколько раз натыкался на эту информацию в разных статьях. Сейчас погуглил, нашел только одну статью на хабре habr.com/ru/post/101396 — может надо было пользоваться другим поисковиком? :) Если что еще найду, добавлю ссылки.

                                      Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

                                      Самое читаемое