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

в 9:08, , рубрики: .net, DotLiquid, flowdocument, open source, PDF, PDFSharp, tutorial, wpf, xps, генерация pdf, шаблонизатор, метки: , , , , , , , ,

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

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js