...

вторник, 12 ноября 2013 г.

Генерация PDF из WPF-приложения «для всех, даром, и пусть никто не уйдет обиженный»

Пару недель назад на проекте появилась задача генерации 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 }}" .../>


СПАСИБО ЗА ВНИМАНИЕ!


This entry passed through the Full-Text RSS service — if this is your content and you're reading it on someone else's site, please read the FAQ at fivefilters.org/content-only/faq.php#publishers. Five Filters recommends:



Комментариев нет:

Отправить комментарий