Генерация 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 }}" …/>

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

ссылка на оригинал статьи http://habrahabr.ru/post/201836/

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

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