Хабрахабр

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

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

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

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

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

NET MVC, . Веб приложение на ASP. 6. NET версии 4. Предполагается, что развертывание будет происходить на 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 отчета.

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

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

Page Number

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

Ну и советы в сети щедры на примеры. Согласно документации 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.

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

Будто параметра и не было. Но в реальности добавление --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) ) .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>

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

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

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

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

Это URL, на основе которого конвертер будет достраивать относительные пути для получения импортированных CSS стилей, JavaScirpt файлов или шрифтов. Напоследок я бы хотел отметить, что многие библиотеки конвертации HTML в PDF принимают некий baseUrl как параметр. Я не могу точно утверждать как это работает в 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;
}

Первый класс — page-break-before — подсказывает утилите всегда начинать этим элементом новую страницу. wkhtmltopdf утилиты успешно считывает данные классы и понимает, что необходимо начать новую страницу. Например, у вас есть подряд идущие блоки структурированной информации, или скажем таблицы. Второй класс — 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, поделитесь, пожалуйста.

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

Теги
Показать больше

Похожие статьи

Добавить комментарий

Ваш e-mail не будет опубликован. Обязательные поля помечены *

Кнопка «Наверх»
Закрыть