- PVSM.RU - https://www.pvsm.ru -

Неочевидные особенности применения Rotativa для генерации PDF в ASP.NET MVC приложении

Многие разработчики сталкиваются с задачей создания 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 [1]

  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 [2]. Скорость работы на высоте, можно ставить на 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 [3] содержит полный список всех параметров, среди которых есть --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, поделитесь, пожалуйста. Это было бы весьма полезно.

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

Автор: pinckrow

Источник [7]


Сайт-источник PVSM.RU: https://www.pvsm.ru

Путь до страницы источника: https://www.pvsm.ru/javascript/294935

Ссылки в тексте:

[1] Rotativa: https://www.nuget.org/packages/Rotativa/1.7.3

[2] wkhtmltopdf: https://wkhtmltopdf.org

[3] wkhtmltopdf manual: https://wkhtmltopdf.org/usage/wkhtmltopdf.txt

[4] Rotativa GitHub: https://github.com/webgio/Rotativa

[5] Rotativa Package: https://www.nuget.org/packages/Rotativa

[6] Inline Styles HowTo: https://blog.mariusschulz.com/2015/10/25/inlining-css-and-javascript-bundles-with-asp-net-mvc

[7] Источник: https://habr.com/post/425511/?utm_campaign=425511