Пару недель назад на проекте появилась задача генерации PDF.
Разумеется, я, как разработчик WPF UI, сразу был против сурового подхода кодирования отрисовки всех примитивов PDF в коде C#.
И заказчик был непротив покупки некоего платного конвертера из HTML в PDF, например.
Вроде бы все просто — генерируем строку с HTML-разметкой, используя DotLiquid для шаблонизации, и конвертируем в PDF с помощью одного из множества платных конвертеров.
Единственная засада — плохая совместимость HTML со страничной структурой PDF-документа.
Только я начал закапываться в поисках решения этой проблемы, как один коллега поделился ссылкой на статью с альтернативным решением.
Из статьи я узнал, что есть возможность сгенерировать PDF из XPS-документа (этот формат поддерживается в WPF FlowDocument).
К тому же, для генерации использовалась бесплатная библиотека PDFSharp.
Исходники можете скачать с GitHub.
Дисклеймер
Представляемые Вашему вниманию исходные коды не представляют собой примера для подражания. Чтобы не затягивать со статьей, я не стал следовать каким бы то ни было паттернам проектирования. В исходниках простой «Code Behind» подход. Это сделано еще и для простоты восприятия сути, т.е. для фокусировки на самой генерации PDF. Думаю вы легко сможете интегрировать основные куски кода в структуру Вашего проекта.
Так же в исходниках Вы встретите массивное использование dynamic в качестве источника данных для шаблона DotLiquid. Это тоже было сделано в основном для простоты и скорости. На сайте DotLiquid есть описание как аннотировать Ваши собственные классы, чтобы они могли быть использованы в шаблоне. Тут Вы тоже легко сможете адаптировать мои исходники под свои нужды.
Ну и еще стоит упоминуть, что у PDFSharp мной была обнаружена проблема с псевдо-шрифтами FlowDocument / XPS. В частности, отрендеренные маркеры ненумированного списка из XPS экспортуруются в PDF в виде пустых квадратиков. В режиме дебага я получал сообщения Debug.Assert(...) с ошибкой импортирования / экспортирования шрифтов. Эту проблему пока не исследовал. Проблему со списками легко обойти с помощью шаблона.
Подготовка
Ниже представлен список необходимых манипуляций:
- Идем на сайт про модифицированный PDFSharp и качаем оттуда скомпилированные сборки либо сами исходники. Альтернативой может служить PDFSharp версий 1.2 — 1.31, включительно.
- Устанавливаем библиотеку DotLiquid (версия 1.7.0 на момент написания статьи) с помощью NuGet (установите Nuget, если еще не сделали этого)
- Добавьте ссылки на сборки System.Printing и ReachFramework к проекту, в котором будет производится генерация PDF
Главное окно
Ниже представлена разметка главного окна.
<Window x:Class="Solution.MainWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="MainWindow" Height="480" Width="640">
<Grid>
<Grid.RowDefinitions>
<RowDefinition></RowDefinition>
<RowDefinition Height="Auto"></RowDefinition>
</Grid.RowDefinitions>
<FlowDocumentReader x:Name="DocViewer">
<FlowDocument>
<FlowDocument.Resources>
<Style TargetType="TextBlock">
<Setter Property="FontSize" Value="14"/>
<Setter Property="Margin" Value="5"/>
</Style>
</FlowDocument.Resources>
<BlockUIContainer>
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition />
</Grid.ColumnDefinitions>
<Ellipse Fill="#003481" Width="5" Height="5" Margin="5"/>
<TextBlock Text="Title" FontWeight="Bold" Grid.Column="1"/>
<TextBlock Text="Description" Grid.Column="2"/>
</Grid>
</BlockUIContainer>
</FlowDocument>
</FlowDocumentReader>
<StackPanel Grid.Row="1" Orientation="Horizontal">
<Button Click="ParseButton_OnClick">Parse</Button>
<Button Click="ButtonBase_OnClick">Print</Button>
</StackPanel>
</Grid>
</Window>
Здесь мы видим FlowDocumentReader, который будет отображать отрендеренный FlowDocument. В разметке Вы также можете видеть захардкоженый FlowDocument, который я использую для создания шаблона с помощью дизайнера в Visual Studio.
Также Вы можете видеть, что я использую обычные контролы и стили WPF. В этом один из огромных бонусов использования FlowDocument для генерации PDF. Я могу использовать контролы и ресурсы стилей своего WPF приложения. Для подхода с HTML в качестве посредника пришлось бы отдельно поддерживать сборку CSS стилей и кусков HTML, которые еще как-то необходимо будет внедрить в шаблон.
Контекст данных для шаблона
Для генерации контекста данных я добавил в Code Behind главного окна приватный метод, в котором захардкожено создание DotLiquid.Hash для dynamic-объекта.
private DotLiquid.Hash CreateDocumentContext()
{
var context = new
{
Title = "Hello, Habrahabr!",
Subtitle = "Experimenting with dotLiquid, FlowDocument and PDFSharp",
Steps = new List<dynamic>{
new { Title = "Document Context", Description = "Create data source for dotLiquid Template"},
new { Title = "Rendering", Description = "Load template string and render it into FlowDocument markup with Document Context given"},
new { Title = "Parse markup", Description = "Use XAML Parser to prepare FlowDocument instance"},
new { Title = "Save to XPS", Description = "Save prepared FlowDocument into XPS format"},
new { Title = "Convert XPS to PDF", Description = "Convert XPS to WPF using PDFSharp"},
}
};
return DotLiquid.Hash.FromAnonymousObject(context);
}
Как я написал в дисклеймере, это просто пример. В реальном проекте у Вас должен быть некий конвертер для реальных DTO или ViewModel.
В мануале для разработчика на странице DotLiquid написано, что в шаблоне нельзя просто так использовать экземпляр некоего произвольного класса для вывода строкового значения. Если Вы в шаблоне пропишете вывод, например, объекта DateTime, то в отрендеренный документ попадет просто вывод ToString() без параметров. А вот если шаблону подвернется созданный Вами объект, например какой-нибудь BlaBlaUser, то DotLiquid вместо него выведет строку с ошибкой. И это, кстати, очень хорошо, т.к. Вы сразу увидите конкретное место где Вы ошиблись, при этом все равно шаблон будет отрендерен.
Шаблон
<FlowDocument xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<FlowDocument.Resources>
<Style TargetType="TextBlock">
<Setter Property="FontSize" Value="14"/>
<Setter Property="Margin" Value="5"/>
<Setter Property="TextWrapping" Value="Wrap"/>
</Style>
</FlowDocument.Resources>
<Paragraph FontSize="24">
<Bold>{{ Title }}</Bold>
</Paragraph>
<Paragraph FontSize="16">
{{ Subtitle }}
</Paragraph>
<Paragraph FontSize="16">
<Bold>Steps to generate PDF:</Bold>
</Paragraph>
{% for step in Steps -%}
<BlockUIContainer>
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition />
</Grid.ColumnDefinitions>
<Ellipse Fill="#003481" Width="5" Height="5" Margin="5"/>
<TextBlock Text="{{ step.Title }}" Foreground="#003481" FontWeight="Bold" Grid.Column="1"/>
<TextBlock Text="{{ step.Description }}" Grid.Column="2"/>
</Grid>
</BlockUIContainer>
{% endfor -%}
</FlowDocument>
Имейте в виду, вместо вставки биндинга к контексту DotLiquid напрямую в аттрибуте TextBlock.Text надежнее будет использовать вложенный блок CDATA:
<TextBlock Foreground="#003481" FontWeight="Bold" Grid.Column="1">
<![CDATA[
{{ step.Title }}
]]>
</TextBlock>
Это обезопасит Вас от символов, несовместимых с XML-форматом.
Рендеринг и парсинг FlowDocument
private void ParseButton_OnClick(object sender, RoutedEventArgs e)
{
using (var stream = new FileStream("Templates\report1.lqd", FileMode.Open))
{
using (var reader = new StreamReader(stream))
{
var templateString = reader.ReadToEnd();
var template = dotTemplate.Parse(templateString);
var docContext = CreateDocumentContext();
var docString = template.Render(docContext);
DocViewer.Document = (FlowDocument) XamlReader.Parse(docString);
}
}
}
Тут все просто. Открываем поток файла с шаблоном, создаем контекст шаблона и рендерим разметку FlowDocument. С помощью XamlReader'а парсим полученную разметку и помещаем созданный экземпляр в наш FlowDocumentReader. Если нас все устраивает, то переходим к конвертации этого документа в PDF.
Генерация PDF
private void ButtonBase_OnClick(object sender, RoutedEventArgs e)
{
using (var stream = new FileStream("doc.xps", FileMode.Create))
{
using (var package = Package.Open(stream, FileMode.Create, FileAccess.ReadWrite))
{
using (var xpsDoc = new XpsDocument(package, CompressionOption.Maximum))
{
var rsm = new XpsSerializationManager(new XpsPackagingPolicy(xpsDoc), false);
var paginator = ((IDocumentPaginatorSource)DocViewer.Document).DocumentPaginator;
rsm.SaveAsXaml(paginator);
rsm.Commit();
}
}
stream.Position = 0;
var pdfXpsDoc = PdfSharp.Xps.XpsModel.XpsDocument.Open(stream);
PdfSharp.Xps.XpsConverter.Convert(pdfXpsDoc, "doc.pdf", 0);
}
}
И здесь все просто. Генерируется package XPS-документа (как известно, XPS — это zip-архив cо множеством XML и прочих ресурсов). Отрендеренный нами ранее FlowDocument сохраняется в созданный XPS-пакет. (До закрытия!) потока XPS-пакета производится загрузка XPS-документа средствами PDFSharp. После этого загруженный XPS конвертируется в PDF.
Заключение
В заключение хочется привести список преимуществ, которые я выделил для себя в таком подходе.
- Бесплатность — нам удалось решить одну из важных бизнесс-задач с помощью бесплатных библиотек (MIT)
- FlowDocument в качестве посредника — это практически нативная поддержка страничной структуры и возможность использования WPF контролов внутри документа
- Стилизация — благодаря использованию FlowDocument имеется возможность стилизации документа WPF стилями
- Интерактивность — т.к. можно использовать WPF контролы, то до «распечатки» в PDF пользователь сможет произвести некие изменения и вычисления в документе, если потребуется. Даже применение Binding возможно в таком случае (правда есть с этим некоторые проблемы — нужен пинок для Dispatcher для запуска обновления Binding).
- Visual Designer — я могу пользоваться привычным дизайнером Visual Studio при подготовке шаблона. Единственное огорчение — биндинги DotLiquid вида "{{ someProp }}" несовместимы с разметкой XAML. Можно обойти вставкой в начале "{}": <TextBlock Text="{}{{ step.Title }}" .../>
СПАСИБО ЗА ВНИМАНИЕ!
Автор: HomoLuden