Как web-страницу легко превратить в PDF?

  • Tutorial

Для меня было очень неожиданно то, что в хабе по Java практически нет информации по работе с PDF документами, поэтому я, из личного опыта, хочу на примере сервлета показать как легко можно любую web-страницу превратить в PDF документ.

Преамбула:
Напишем простой сервлет, который будет брать указанную нами web-страницу по HTTP протоколу и генерировать на её основе полноценный PDF документ.

Используемые библиотеки:
  • Flying Saucer PDF — основная библиотека, которая поможет создать нам PDF документ из HTML/CSS
  • iText — библиотека, которая включена в состав той, что описана выше, но я не мог не включить ее в список библиотек, т.к. именно на основе неё будет генерироваться PDF документ
  • HTML Cleaner — библиотека, которая будет приводить наш HTML код в порядок

Описания библиотек для Maven конфигурации (pom.xml)
        <dependency>
            <groupId>org.xhtmlrenderer</groupId>
            <artifactId>flying-saucer-pdf</artifactId>
            <version>9.0.4</version>
        </dependency>

        <dependency>
            <groupId>net.sourceforge.htmlcleaner</groupId>
            <artifactId>htmlcleaner</artifactId>
            <version>2.6.1</version>
        </dependency>


Формирование страницы:
Одним из самый важных моментов является формирование страницы. Дело в том, что именно из самой страницы, посредством CSS, задаются параметры будущего PDF документа.

Рассмотрим макет:
page.jsp
<%@ page import="java.util.Date" %>
<%@ page import="java.text.SimpleDateFormat" %>
<%@ page contentType="text/html;charset=UTF-8" language="java" %>
<%!
    private SimpleDateFormat sdf = new SimpleDateFormat("HH:mm:ss");
%>
<html>
<head>
    <title>Пример</title>
    <style>
        @font-face {
            font-family: "HabraFont";
            src: url(http://localhost:8080/resources/fonts/tahoma.ttf);
            -fs-pdf-font-embed: embed;
            -fs-pdf-font-encoding: Identity-H;
        }

        @page {
            margin: 0px;
            padding: 0px;
            size: A4 portrait;
        }

        @media print {
            .new_page {
                page-break-after: always;
            }
        }

        body {
            background-image: url(http://localhost:8080/resources/images/background.png);
        }

        body *{
            padding: 0;
            margin: 0;
        }

        * {
            font-family: HabraFont;
        }

        #block {
            width: 90%;
            margin: auto;
            background-color: white;
            border: dashed #dbdbdb 1px;
        }

        #logo {
            margin-top: 5px;
            width: 100%;
            text-align: center;
            border-bottom: dashed #dbdbdb 1px;
        }

        #content {
            padding-left: 10px;
        }

    </style>
</head>
<body>
<div id="block">
    <div id="logo"><img src="http://localhost:8080/resources/images/habra-logo.png"></div>
    <div id="content">
        Привет, хабр! Текущее время: <%=sdf.format(new Date())%>
        <div class="new_page"> </div>
        Новая страница!
    </div>
</div>

</body>
</html>


Здесь хочу остановиться на нескольких моментах. Для начала самое важное: все пути должны быть абсолютными! Картинки, стили, адреса шрифтов и др., на всё должны быть прописаны абсолютные пути. А теперь пройдемся по CSS правилам (то, что начинается с символа @).
@ font-face — это правило, которое скажет нашему PDF генератору какой нужно взять шрифт, и откуда. Проблема в том, что библиотека, которая будет генерировать PDF документ не содержит шрифтов, включающих в себя кириллицу. Именно поэтому таким образом придется определять ВСЕ шрифты, которые используются в Вашей странице, пусть это будут даже стандартные шрифты: Arial, Verdana, Tahoma, и пр., в противном случае Вы рискуете не увидеть кириллицу в Вашем документе.
Обратите внимание на такие свойства как "-fs-pdf-font-embed: embed;" и "-fs-pdf-font-encoding: Identity-H;", эти свойства необходимы, их просто не забывайте добавлять.
@ page — это правило, которое задает отступы для PDF документа, ну и его размер. Здесь хотелось бы отметить, что если Вы укажите размер страницы A3 (а как показывает практика, это часто необходимо, т.к. страница не помещается в документ по ширине), то это не значит, что пользователю необходимо будет распечатывать документ (при желании) в формате A3, скорее просто весь контент будет пропорционально уменьшен/увеличен до желаемого (чаще A4). Т.е. относитесь к значению свойства size скептически, но знайте, что оно может сыграть для Вас ключевую роль.
@ media — правило, позволяющее создавать CSS классы для определенного типа устройств, в нашем случае это «print». Внутри этого правила мы создали класс, после которого наш генератор PDF документа создаст новую страницу.

Сервлет:
Теперь напишем сервлет, который будет возвращать нам сгенерированный PDF документ:
PdfServlet.java
package ru.habrahabr.web_to_pdf.servlets;

import org.htmlcleaner.CleanerProperties;
import org.htmlcleaner.HtmlCleaner;
import org.htmlcleaner.PrettyXmlSerializer;
import org.htmlcleaner.TagNode;
import org.xhtmlrenderer.pdf.ITextRenderer;

import javax.servlet.ServletException;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.*;
import java.net.HttpURLConnection;
import java.net.URL;
import java.net.URLConnection;

/**
 * Date: 31.03.2014
 * Time: 9:33
 *
 * @author Ruslan Molchanov (ruslanys@gmail.com)
 */
public class PdfServlet extends HttpServlet {
    private static final String PAGE_TO_PARSE = "http://localhost:8080/page.jsp";
    private static final String CHARSET = "UTF-8";

    @Override
    protected void service(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
        try {
            resp.setContentType("application/pdf");

            byte[] pdfDoc = performPdfDocument(PAGE_TO_PARSE);

            resp.setContentLength(pdfDoc.length);
            resp.getOutputStream().write(pdfDoc);
        } catch (Exception ex) {
            resp.setContentType("text/html");

            PrintWriter out = resp.getWriter();
            out.write("<strong>Something wrong</strong><br /><br />");
            ex.printStackTrace(out);
            ex.printStackTrace();
        }
    }

    /**
     * Метод, подготавливащий PDF документ.
     * @param path путь до страницы
     * @return PDF документ
     * @throws Exception
     */
    private byte[] performPdfDocument(String path) throws Exception {
        // Получаем HTML код страницы
        String html = getHtml(path);

        // Буффер, в котором будет лежать отформатированный HTML код
        ByteArrayOutputStream out = new ByteArrayOutputStream();

        // Форматирование HTML кода
        /* эта процедура не обязательна, но я настоятельно рекомендую использовать этот блок */
        HtmlCleaner cleaner = new HtmlCleaner();
        CleanerProperties props = cleaner.getProperties();
        props.setCharset(CHARSET);
        TagNode node = cleaner.clean(html);
        new PrettyXmlSerializer(props).writeToStream(node, out);

        // Создаем PDF из подготовленного HTML кода
        ITextRenderer renderer = new ITextRenderer();
        renderer.setDocumentFromString(new String(out.toByteArray(), CHARSET));
        renderer.layout();
        /* заметьте, на этом этапе Вы можете записать PDF документ, скажем, в файл
         * но раз мы пишем сервлет, который будет возвращать PDF документ,
         * нам нужен массив байт, который мы отдадим пользователю */
        ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
        renderer.createPDF(outputStream);

        // Завершаем работу
        renderer.finishPDF();
        out.flush();
        out.close();

        byte[] result = outputStream.toByteArray();
        outputStream.close();
        
        return result;
    }

    private String getHtml(String path) throws IOException {
        URLConnection urlConnection = new URL(path).openConnection();

        ((HttpURLConnection) urlConnection).setInstanceFollowRedirects(true);
        HttpURLConnection.setFollowRedirects(true);

        boolean redirect = false;

        // normally, 3xx is redirect
        int status = ((HttpURLConnection) urlConnection).getResponseCode();
        if (HttpURLConnection.HTTP_OK != status &&
                (HttpURLConnection.HTTP_MOVED_TEMP == status ||
                        HttpURLConnection.HTTP_MOVED_PERM == status ||
                        HttpURLConnection.HTTP_SEE_OTHER == status)) {

            redirect = true;
        }

        if (redirect) {
            // get redirect url from "location" header field
            String newUrl = urlConnection.getHeaderField("Location");

            // open the new connnection again
            urlConnection = new URL(newUrl).openConnection();
        }

        urlConnection.setConnectTimeout(30000);
        urlConnection.setReadTimeout(30000);

        BufferedReader in = new BufferedReader(new InputStreamReader(urlConnection.getInputStream(), CHARSET));

        StringBuilder sb = new StringBuilder();
        String line;
        while (null != (line = in.readLine())) {
            sb.append(line).append("\n");
        }

        return sb.toString().trim();
    }

    @Override
    public String getServletInfo() {
        return "The servlet that generate and returns pdf file";
    }
}


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

В конечном итоге у Вас должен получиться примерно такой PDF документ: github.com/ruslanys/example-web-to-pdf/blob/master/web-to-pdf-example.pdf
Я немного дополнил свой документ информацией (распарсил главную страницу Хабра) и у меня получился такой вот документ: github.com/ruslanys/sample-html-to-pdf/blob/master/web-to-pdf-habra.pdf

Ссылка на исходники: github.com/ruslanys/sample-html-to-pdf

P.S. В принципе, на основе этого примера можно написать целый сервис, который будет по любому адресу страницы создавать PDF документ. Единственное, что будет необходимо сделать — это привести HTML код страницы в соответствие с нашими правилами, т.е. в первую очередь нужно будет переписать все относительные пути на абсолютные (благо это делается не сложно), и в соответствии с какой-то логикой задать размеры документа.
Support the author
Share post
AdBlock has stolen the banner, but banners are not teeth — they will be back

More
Ads

Comments 25

    0
    Почти оффтоп. А Хром умеет ват такие пдф делать – db.tt/moFOVfSX
      +1
      Это здорово конечно, Chrome молодец, но если Вы не уловили суть, в статье рассмотрено построение PDF документа на серверной стороне, а не на клиентской.
        +4
        wkhtmltopdf.org/
        пробовал, очень качественно.
          +3
          Я тоже какое-то время пользовался. Сейчас мне больше нравится phantomjs, так как более гибок.
            –1
            Единственно он тянет за собой js на сервер. Хотя, по-мойму на современном сервере уже все на нём должно быть — php, python, ruby, js, bash :))

            Puppet нам в помощь.
              0
              update: Хотя вру — там просто бинарник.
                0
                Да-да, точно, собираешь библиотеки из разных языков и пытаешься из всего это сделать один проект)
            0
            Нда, я поэтому и написал, что почти оффтоп. Ещё и в карме отразилось непонимание. Что-то Хабр суров нынче.
              0
              Действительно не понял, а насчёт кармы полностью согласен)
          0
          Спасибо, в целом очень интересные библиотеки, хотя если говорить в разрезе языка программирования Java, то wkhtmltopdf ещё можно попытаться подключить через JNI, но вопрос на сколько это уместно, а phantomjs тем более вряд ли подходит.
            0
            Очень много геморроя, уж простите и вопрос в том насколько хорошо ваше решение дружит с css3 html5? А ответ прост — вообще не дружит.
            Так что разговоры типа можно создать свой сервис, это чисто болтовня и бред, с такими ограничениями можно текстовый странички в пдф гнать не более.

            В свое время я активно искал как бы преобразовывать html в pdf на java. В итоге ничего лучше wkhtmltopdf.org найти не удалось, да, получается сторонний вызов из левого места, но что поделать.

            А еще если вы сами формируете этот html, лучше сразу взять тот же ireport и делать на нем в итоге выбирая куда конвертить в html или pdf (помню из-за бага в вебките wkhtmltopdf.org неправильно преобразовывал таблицы. пришлось все переделывать )
              0
              Так, ну что касается HTML5 и CSS3, я сомневаюсь, что есть хорошие аналоги, которые смогут превратить документ такой структуры в PDF, возможно ли это вообще в полном объеме? Пару лет назад видел на HTML5 и CSS3 игру Angry Birds (http://chrome.angrybirds.com/), скажите, как вы приведете такую страницу к PDF? Что касается wkhtmltopdf, согласен, что это хорошая вещь, но на мой личный взгляд в данном случае
              сторонний вызов из левого места
              гораздо геморойнее (как Вы выразились), чем то решение, которое я предложил.
                –1
                Я вот утверждаю что вызов укладывается в несколько строк кода кода , не без проблем но!

                На счет HTML5 и CSS3 — wkhtmltopdf их понимает и сгенерирует более менее нормальное отображения поняв основные вещи.

                Пару лет назад видел на HTML5 и CSS3 игру Angry Birds (http://chrome.angrybirds.com/), скажите, как вы приведете такую страницу к PDF?

                А вы еще скажите чтобы в нее, в пдф можно было поиграть, подумайте над абсурдностью вашего заявления.
                  +1
                  Ну я вел к абсурдности Вашего заявления.
                    0
                    Дефакто я не видел ни одного вменяемого инструмента на java да и быть их не может ибо написать такой инструмент — огромный нереальный труд. Чтобы сделать правильный pdf из html надо распорсить html и применить к нему css, а для этого надо написать вещь аналогичную webkit или geko чтопо сути гигантская область работы.
                    Поэтому все что якобы может работать нативно на java на уровне рендеринга html документов — мягко говоря отстает лет на 10 от текущих веб технологий.

                    Поэтому я говорю если вы хотите какие-то данные отображать и в html и в pdf — берите ireport. Если вы надеятесь работать с реальными сайтами то смотрите в сторону сторонних решений.

                    Вы же в статье тоже не хабр в пдф гоните а какую-то непотнятную табличу в 2 строки, по сложности рендеринга — вообще ниочем, даже говорить не очем. Возьмите реальную страницу хабра и посмотрите на свое решение и wkhtmltopdf, сравните качество.
                      0
                      О чем мы спорим? :) Я не считаю, что спорить к месту, я соглашусь с тем, что wkhtmltopdf очень хороша, и соглашусь, что такой подход — тоже выход, но так по сути мы можем найти любую программу, которая способна сгенерировать PDF из веб-страницы, вызовем ее из нашего приложения, подождем пока она закончит свою работу, и отдадим документ клиенту. Чувствуете разницу? В статье я рассмотрел нативный способ на Java, единственный из известных мне, этим и поделился, а использовать его, или возложить обязанности на вызов другой программы — это уже другой разговор, Вы так не считаете? Я лишь считаю, что описанный мною способ тоже имеет право на существование. Основываясь на личном опыте, скажу, что данный способ я успешно использовал в американском проекте, все остались довольны.

                      Что касается хабра, вот Вам пожалуйста страница из хабра в PDF на том решении, которое я предложил: db.tt/14Z5dIgF
                        0
                        Вот, кстати, PDF, которую сделал wkhtmltopdf: db.tt/hD2qKBxn

                        В целом, качество wkhtmltopdf отличное: создано оглавление, приятно смотрится документ, только вот беда с заголовками постов, заметили?
                          0
                          Предположу что это реакция на letter-spacing и это фиксится подключением вот такого css
                          h1 {
                             letter-spacing: -1px;
                          }
                          


                          Попробуйте вашим методом, сколько потребуется фиксов чтобы адеватно поправить все?
                            0
                            Так, в общем, я вижу, что каждый остается при своем мнении, и разговор в общем-то никуда нас не приведет. Спасибо за дискуссию, больше не вижу смысла поддерживать эту беседу.
                          0
                          Такие титаны еще существуют.

                          Я даже одно такое решение pure java на дотнет портировал, о чем писал на хабре.

                          Вполне себе честный был парсинг HTML с не менее честным применением CSS в объеме спеки 2.1 + элементы CSS 3. Порядка 400 000 строк кода было. С рендерилкой SVG даже. И reflow по заданному размеру страницы, чего скажем wkhtmltopdf не умеет.
                  0
                  А как насчёт закладок? Создаёт ли ваше решение структуру закладок на основании html-заголовков?
                  +1
                  Спасибо за пост.
                  Используем аналогичный подход, только HTML генерируем при помощи XSLT.
                  До внедрения проводили обзор способов генерации PDF из Html. Получилась вот такая табличка gyazo.com/87913f3008f4023cd11db01134406515

                  Одним из требования была поддержка постраничных хедеров/футеров, нумерации страниц и переноса заголовков таблиц между страницами (если таблица большая).

                  Летающая тарелка одна из немногих кто поддерживает margin-boxes и running elements из CSS3, что и позволило реальзовать всё что нужно.

                    0
                    Ой, большое спасибо за приятный отзыв. Кстати, отличная таблица получилась у вас, очень полезная, спасибо!
                    0
                    А я делал так: через html2canvas конвертирую блочный элемент в canvas, из него получаю base64 Data URI изображения, отсылаю его на сервер, а там создаю PDF-файл с этой картинкой. Картинка получается гарантированно такая же, какая была на клиенте. Только немного заблюренная, и текст там не выбирается, но мне это было не важно. Для canvas требуется поддержка HTML5.

                    Only users with full accounts can post comments. Log in, please.