Многие разработчики сталкиваются с задачей создания PDF отчетов для веб приложений, вполне естественный запрос. Я бы хотел представить вашему вниманию свой опыт работы с такой задачей при использовании библиотеки Rotativa для генерации отчетов. Это одна из самых, на мой взгляд, удобных библиотек для такой цели в своем сегменте, но при использовании ее я столкнулся с несколькими не очевидными моментами, о которых и хочу поговорить.


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


В данном материале я не буду касаться вопроса выбора библиотеки. У каждого могут быть свои причины использовать ту или иную. Я выбрал Rotativa, потому что в ней при минимальных затратах на настройку завелось все необходимое для покрытия требований заказчика. Кроме нее я попробовал еще три или четыре варианта.


Постановка задачи


Веб приложение на ASP.NET MVC, .NET версии 4.6. Прочие особенности значения не имеют в данном контексте, за исключением деплоймента. Предполагается, что развертывание будет происходить на Azure. Это важно, так как некоторые другие библиотеки (например HiQPdf) не переносят установки в определенных конфигурациях Azure, это документировано.


Мне необходимо по одной ссылке открывать некий статичный HTML отчет, а по второй ссылке — PDF версию того же отчета. Сам отчет суть просто набор некоторых таблиц, полей и графиков для демонстрации пользователю. Обе версии предполагают наличие меню с навигацией по разделам отчета, наличие таблиц, некоторой графики (цвета, размер текста, бордеры).


Применение библиотеки Rotativa


Rotativa применяется максимально легко, насколько это вообще возможно на мой взгляд.


  1. У вас уже есть готовый HTML отчет в виде шаблона и контроллера ASP.NET MVC, типа такого:

[HttpGet]
public async Task<ActionResult> Index(int param1, string param2)
{
    var model = await service.GetReportDataAsync(param1, param2);
    return View(model);
}

  1. Устанавливаете nuget пакет Rotativa


  2. Добавляете новый контроллер для PDF отчета



[HttpGet]
public async Task<ActionResult> Pdf(int param1, string param2)
{
    var model = await service.GetReportDataAsync(param1, param2);            
    return new ViewAsPdf("Index", model);
}

По сути с этого момента у вас есть PDF, возвращаемый как файл, содержащий все данный из исходного HTML отчета.


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


Интересно, что сама данная библиотека по сути является оберткой над известной консольной утилитой wkhtmltopdf. Скорость работы на высоте, можно ставить на Azure — будет работать. Но есть особенности, о которых и поговорим.


Page Number


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


Согласно документации Rotativa, через параметр CustomSwitches можно указать аргументы, которые будут переданы в саму утилиту wkhtmltopdf. Ну и советы в сети щедры на примеры. Следующий вызов добавляет номер в нижнюю часть каждой страницы:


return new ViewAsPdf("Index", model)
{
    PageMargins = new Rotativa.Options.Margins(10, 10, 10, 10),
    PageSize = Rotativa.Options.Size.A4,
    PageOrientation = Rotativa.Options.Orientation.Portrait,
    CustomSwitches = "--page-offset 0 --footer-center [page] --footer-font-size 8
};

Это прекрасно работает. Сам номер страницы передается с помощью параметра [page], такого рода параметры будут подменены на конкретные значения.


Кроме [page] есть и другие:
  • [page] Replaced by the number of the pages currently being printed
  • [frompage] Replaced by the number of the first page to be printed
  • [topage] Replaced by the number of the last page to be printed
  • [webpage] Replaced by the URL of the page being printed
  • [section] Replaced by the name of the current section
  • [subsection] Replaced by the name of the current subsection
  • [date] Replaced by the current date in system local format
  • [isodate] Replaced by the current date in ISO 8601 extended format
  • [time] Replaced by the current time in system local format
  • [title] Replaced by the title of the of the current page object
  • [doctitle] Replaced by the title of the output document
  • [sitepage] Replaced by the number of the page in the current site being converted
  • [sitepages] Replaced by the number of pages in the current site being converted


Table of content


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


wkhtmltopdf manual содержит полный список всех параметров, среди которых есть --toc. Видя данный параметр, утилита по сути собирает все тэги <h1>, <h2>, ... <h6> по документу и генерирует таблицу содержания на их основе. Соответственно необходимо предусмотреть правильное использование этих заголовочных тэгов у себя в HTML шаблоне.


Но в реальности добавление --toc не приводит ни к каким последствиям. Будто параметра и не было. При этом другие параметры работают. Благодаря посту на каком-то форуме я обнаружил, что данный параметр нужно передавать без дефисов: toc. И действительно, в этом случае содержание добавляется как самая первая страница. При нажатии на строку в содержании происходит переход на нужную страницу документа, номера страниц верные.


Не до конца понятно пока как настраивать стили, но я пока этим не занимался.


JavaScript Execution


Следующий момент, с которым я столкнулся, необходимость добавить в отчет графики. Моя HTML страница содержит JS код, добавляющий графики с помощью библиотеки dc.js. Вот пример:


function initChart() {
    renderChart(@Html.Raw(Json.Encode(Model.Chart_1_Data)), 'chartDiv_1');
}

function renderChart(data, chartElementId) {
    var colors = ['#03a9f4', '#67daff', '#8bc34a'];
    var barHeight = 45;
    var clientHeight = data.length * barHeight + 50;
    var clientWidth = document.getElementById(chartId).offsetWidth;

    var chart = dc.rowChart('#' + chartElementId);
    var ndx = crossfilter(dataToRender);
    var dimension = ndx.dimension(d => d.name);
    var group = dimension.group().reduceSum(d => d.value);

    chart
        .width(clientWidth)
        .height(clientHeight)
        .margins({ top: 16, right: 16, bottom: 16, left: 16 })
        .ordinalColors(colors)
        .dimension(dimension)
        .group(group)
        .xAxis()
        .scale(d3.scaleLinear().domain([0, 2]).range([1, 3]).nice());
    chart.render();
}

При этом в HTML у меня есть соответсвующий элемент:


<div id="chart_C2" class="dc-chart"></div>

Для работоспособности этого кода необходимо импортировать соответствующие библиотеки: dc.js, d3.js, crossfilter.js. Вызов функции initChart создаст график и вставит полученный
svg в указанный элемент в дереве.


Но PDF не содержит и следа графиков. Как впрочем и любого друго следа выполнения JavaScript кода перед рендерингом PDF. Проверить это довольно легко — стоит лишь добавить элементарный код создания простого <div> элемента с текстом, просто для тестирования факта вызова JavaScript.


Опытным путем выяснилось, что местоположения JS кода для wkhtmltopdf играет существенную роль. Расположенный в конце <html> или скажем в конце <body> JS код выполнен не будет. Такое ощущение, что утилита его просто не замечает, или не ожидает его там встретить.


А вот код, расположенный внутри <head> выполняется. Таким образом я пришел к схеме, когда JavaScript код располагается после объявления стилей внутри тэга <head>, и вызывается обычной конструкцией:


<body onload="initCharts()">

В таком случае код будет выполнен ожидаемым образом.


JavaScript Limitations


Но графиков в выходном PDF все равно не было. Тут я начал догадываться, что будучи не полноценным браузером, движок рендеринга и исполнения pdf скорее всего не совершенен и не понимает последних правил. Опять же опытным путем я выяснил, что стрелочные функции не воспринимаются. Причем если интерпретатор находит что-то для него неизвестное, то он просто перестает работать.


Замена стрелочных функций вида x => x.value на более классические function(x) { return x.value; } помогла и весь код был выполнен, результирующий график попал в PDF файл.


Chart Width


Опытным путем выяснилось, что обязательно необходимо четко указать ширину родительского элемента графика. Для этого я указал стиль dc-chart. Он содержит ширину графика в пикселях. В противном случае график на PDF окажется очень маленьким, несмотря на то, что в HTML версии он займет всю ширину. Указание ширины в процентах сработает только для HTML.


Inline JavaScript / CSS


Напоследок я бы хотел отметить, что многие библиотеки конвертации HTML в PDF принимают некий baseUrl как параметр. Это URL, на основе которого конвертер будет достраивать относительные пути для получения импортированных CSS стилей, JavaScirpt файлов или шрифтов. Я не могу точно утверждать как это работает в Rotativa, но я пришел к другому подходу.


Для ускорения первичной загрузки отчета и устранения самого источник проблем встраивания файлов скриптов или стилей при конвертации, я встраиваю нужный JS и CSS непосредственно в тело HTML шаблона.


Для этого необходимо создать соответствующие бандлы:


public class BundleConfig
{
    public static void RegisterBundles(BundleCollection bundles)
    {
        bundles.Add(new StyleBundle("~/Styles/report-html")
                .Include("~/Styles/report-common.css")
                .Include("~/Styles/report-html.css")
        );

        bundles.Add(new StyleBundle("~/Styles/report-pdf")
                .Include("~/Styles/report-common.css")
                .Include("~/Styles/report-pdf.css")
        );

        bundles.Add(new ScriptBundle("~/Scripts/charts")
                .Include("~/Scripts/d3/d3.js")
                .Include("~/Scripts/crossfilter/crossfilter.js")
                .Include("~/Scripts/dc/dc.js")
        );
    }
}

Добавить вызов конфигурации этих бандлов в Global.asax.cs


protected void Application_Start()
{
    ...
    BundleConfig.RegisterBundles(BundleTable.Bundles);
}

И добавить соответствующий метод для встраивания кода в страницу. Его нужно разместить в том же namespace, что и Global.asax.cs, чтобы метод можно было вызвать из HTML шаблона:


public static class HtmlHelperExtensions
{
    public static IHtmlString InlineStyles(this HtmlHelper htmlHelper, string bundleVirtualPath)
    {
        string bundleContent = LoadBundleContent(htmlHelper.ViewContext.HttpContext, bundleVirtualPath);
        string htmlTag = $"<style rel=\"stylesheet\" type=\"text/css\">{bundleContent}</style>";
        return new HtmlString(htmlTag);
    }

    public static IHtmlString InlineScripts(this HtmlHelper htmlHelper, string bundleVirtualPath)
    {
        string bundleContent = LoadBundleContent(htmlHelper.ViewContext.HttpContext, bundleVirtualPath);
        string htmlTag = $"<script type=\"text/javascript\">{bundleContent}</script>";
        return new HtmlString(htmlTag);
    }

    private static string LoadBundleContent(HttpContextBase httpContext, string bundleVirtualPath)
    {
        var bundleContext = new BundleContext(httpContext, BundleTable.Bundles, bundleVirtualPath);
        var bundle = BundleTable.Bundles.Single(b => b.Path == bundleVirtualPath);
        var bundleResponse = bundle.GenerateBundleResponse(bundleContext);
        return bundleResponse.Content;
    }
}

Ну и финальный штрих — вызов из шаблона:


@Html.InlineStyles("~/Styles/report-pdf");
@Html.InlineScripts("~/Scripts/charts");

В результате весь нужный CSS и JavaScript окажется непосредственно в HTML, хотя при разработке можно работать с отдельными файлами.


Скорее всего, многие сразу же подумают о неэффективности такого подхода с точки зрения кэширования запросов браузером. Но я преследовал две конкретные цели:


  • чтобы PDF конвертеру не приходилось делать запросы куда-то за стилями или кодом, а пользователю ждать этого, соответственно;
  • чтобы первая загрузка PDF и HTML отчета занимала минимальное время, без необходимости ожидания нескольких дополнительных запрос. В условиях моего проекта это важно;

Page Breaks


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


.page-break-before {
    page-break-before: always;
}

.no-page-break-inside {
    page-break-before: auto;
    page-break-inside: avoid;
}

wkhtmltopdf утилиты успешно считывает данные классы и понимает, что необходимо начать новую страницу. Первый класс — page-break-before — подсказывает утилите всегда начинать этим элементом новую страницу. Второй класс — no-page-break-inside — стоит применять к тем элементам, которые желательно сохранить максимально целыми на странице. Например, у вас есть подряд идущие блоки структурированной информации, или скажем таблицы. Если два блока умещаются на страницу — они так и расположатся. Если третий уже не влезает в страницу — он будет не следующей. Если он больше страницы — тут уже неизбежен его перенос. Работает все это адекватно и удобно.


Flex Behavior in wkhtmltopdf


Ну и последняя замеченная мной особенность связана с использованием flexbox стилей разметки. Мы все уже привыкли к ним и почти вся разметки сделана флексами. Однако wkhtmltopdf в этом плане чуть отстал. Горизонтальные опции флекса не работают (по крайней мере в моем случае этого добиться не получилось. Я видел в сети упоминания, что стоит дублировать флекс стили следующими:


display: -webkit-flex;
display: flex;

flex-direction: row;
-webkit-flex-direction: row;
-webkit-box-pack: justify; /* wkhtmltopdf uses this one */
-webkit-justify-content: space-between;
justify-content: space-between;

Но к сожалению к ожидаемой разметке в PDF это не привело. Мне пришлось переделывать разметку некоторых элементов, чтобы горизонтальное размещение блоков было согласно требованиям. Если у кого-то есть успешный опыт интеграции флексов для wkhtmltopdf, поделитесь, пожалуйста. Это было бы весьма полезно.


Некоторые ссылки:


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


  1. ThunderCat
    05.10.2018 21:11
    +2

    exec('google-chrome --headless --no-sandbox --disable-gpu --print-to-pdf=путь_К_Пдф.pdf --no-margins путь_где_брать_хтмл.html --virtual-time-budget=50000 >куда_писать_лог.log 2>&1');

    Достаточно попросить верстальщика сделать хтмл «как в пдф будет». И проверять можно по Ctrl+P как получится финальный пдф — 1 в 1.


    1. pinckrow Автор
      08.10.2018 12:38

      Спасибо за инфу! цель была чутка не в том, но этот вызов удобно было бы использовать для тестирования по крайней мере. Моя проблема была в том, что wkhtmltopdf (вернее тот html движок, который он использует) разбирает HTML и CSS по своему, отлично от хрома.