- PVSM.RU - https://www.pvsm.ru -
Многие разработчики сталкиваются с задачей создания PDF отчетов для веб приложений, вполне естественный запрос. Я бы хотел представить вашему вниманию свой опыт работы с такой задачей при использовании библиотеки Rotativa для генерации отчетов. Это одна из самых, на мой взгляд, удобных библиотек для такой цели в своем сегменте, но при использовании ее я столкнулся с несколькими не очевидными моментами, о которых и хочу поговорить.
Если быть до конца честным, я бы хотел поделиться тем набором граблей, на которые наступил в процессе интеграции данной библиотеки, без сомнения быстрой и очень удобной.
В данном материале я не буду касаться вопроса выбора библиотеки. У каждого могут быть свои причины использовать ту или иную. Я выбрал Rotativa, потому что в ней при минимальных затратах на настройку завелось все необходимое для покрытия требований заказчика. Кроме нее я попробовал еще три или четыре варианта.
Веб приложение на ASP.NET MVC, .NET версии 4.6. Прочие особенности значения не имеют в данном контексте, за исключением деплоймента. Предполагается, что развертывание будет происходить на Azure. Это важно, так как некоторые другие библиотеки (например HiQPdf) не переносят установки в определенных конфигурациях Azure, это документировано.
Мне необходимо по одной ссылке открывать некий статичный HTML отчет, а по второй ссылке — PDF версию того же отчета. Сам отчет суть просто набор некоторых таблиц, полей и графиков для демонстрации пользователю. Обе версии предполагают наличие меню с навигацией по разделам отчета, наличие таблиц, некоторой графики (цвета, размер текста, бордеры).
Rotativa применяется максимально легко, насколько это вообще возможно на мой взгляд.
[HttpGet]
public async Task<ActionResult> Index(int param1, string param2)
{
var model = await service.GetReportDataAsync(param1, param2);
return View(model);
}
Устанавливаете nuget пакет Rotativa [1]
Добавляете новый контроллер для 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 — будет работать. Но есть особенности, о которых и поговорим.
Логично предположить, что заказчик будет распечатывать 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
Большие многостраничные отчеты требуют наличия содержания и навигации по страницам в PDF. Это очень удобно и просто жизненно необходимо, когда количество страниц в отчете переваливает за сотню.
wkhtmltopdf manual [3] содержит полный список всех параметров, среди которых есть --toc
. Видя данный параметр, утилита по сути собирает все тэги <h1>, <h2>, ... <h6>
по документу и генерирует таблицу содержания на их основе. Соответственно необходимо предусмотреть правильное использование этих заголовочных тэгов у себя в HTML шаблоне.
Но в реальности добавление --toc
не приводит ни к каким последствиям. Будто параметра и не было. При этом другие параметры работают. Благодаря посту на каком-то форуме я обнаружил, что данный параметр нужно передавать без дефисов: toc
. И действительно, в этом случае содержание добавляется как самая первая страница. При нажатии на строку в содержании происходит переход на нужную страницу документа, номера страниц верные.
Не до конца понятно пока как настраивать стили, но я пока этим не занимался.
Следующий момент, с которым я столкнулся, необходимость добавить в отчет графики. Моя 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()">
В таком случае код будет выполнен ожидаемым образом.
Но графиков в выходном PDF все равно не было. Тут я начал догадываться, что будучи не полноценным браузером, движок рендеринга и исполнения pdf скорее всего не совершенен и не понимает последних правил. Опять же опытным путем я выяснил, что стрелочные функции не воспринимаются. Причем если интерпретатор находит что-то для него неизвестное, то он просто перестает работать.
Замена стрелочных функций вида x => x.value
на более классические function(x) { return x.value; }
помогла и весь код был выполнен, результирующий график попал в PDF файл.
Опытным путем выяснилось, что обязательно необходимо четко указать ширину родительского элемента графика. Для этого я указал стиль dc-chart
. Он содержит ширину графика в пикселях. В противном случае график на PDF окажется очень маленьким, несмотря на то, что в HTML версии он займет всю ширину. Указание ширины в процентах сработает только для HTML.
Напоследок я бы хотел отметить, что многие библиотеки конвертации 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, хотя при разработке можно работать с отдельными файлами.
Скорее всего, многие сразу же подумают о неэффективности такого подхода с точки зрения кэширования запросов браузером. Но я преследовал две конкретные цели:
Структурирование отчета на разделы может сопровождаться требованиями начать новый раздел с новой страницы. В таком случае можно успешно использовать простой 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
— стоит применять к тем элементам, которые желательно сохранить максимально целыми на странице. Например, у вас есть подряд идущие блоки структурированной информации, или скажем таблицы. Если два блока умещаются на страницу — они так и расположатся. Если третий уже не влезает в страницу — он будет не следующей. Если он больше страницы — тут уже неизбежен его перенос. Работает все это адекватно и удобно.
Ну и последняя замеченная мной особенность связана с использованием 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
Нажмите здесь для печати.