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

  • Tutorial
Пару недель назад на проекте появилась задача генерации PDF.
Разумеется, я, как разработчик WPF UI, сразу был против сурового подхода кодирования отрисовки всех примитивов PDF в коде C#.
И заказчик был непротив покупки некоего платного конвертера из HTML в PDF, например.
Вроде бы все просто — генерируем строку с HTML-разметкой, используя DotLiquid для шаблонизации, и конвертируем в PDF с помощью одного из множества платных конвертеров.
Единственная засада — плохая совместимость HTML со страничной структурой PDF-документа.
Только я начал закапываться в поисках решения этой проблемы, как один коллега поделился ссылкой на статью с альтернативным решением.
Из статьи я узнал, что есть возможность сгенерировать PDF из XPS-документа (этот формат поддерживается в WPF FlowDocument).
К тому же, для генерации использовалась бесплатная библиотека PDFSharp.

Исходники можете скачать с GitHub.

UPD: уже не первый раз наблюдаю как статью плюсуют (первые минусы были сразу после публикации и вряд ли относятся к основному контенту), при этом сливая карму. Мне интересна мотивация, обратная связь. Отпишитесь, кто чем недоволен / доволен, если не затруднит.


Дисклеймер


Представляемые Вашему вниманию исходные коды не представляют собой примера для подражания. Чтобы не затягивать со статьей, я не стал следовать каким бы то ни было паттернам проектирования. В исходниках простой «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 }}" .../>


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

Средняя зарплата в IT

120 000 ₽/мес.
Средняя зарплата по всем IT-специализациям на основании 5 825 анкет, за 1-ое пол. 2021 года Узнать свою зарплату
Реклама
AdBlock похитил этот баннер, но баннеры не зубы — отрастут

Подробнее

Комментарии 5

  • НЛО прилетело и опубликовало эту надпись здесь
      0
      Оно самое… Тут кстати еще интересная вещь проявляется. Если такой FlowDocument с заквотированными биндингами от DotLiquid сконвертировать в XPS, то этого префикса "{}" не будет видно, а вот после сохранения в PDF эти пустые скобки показываются. Еще я заметил, что если для содержимого GroupBox.Header не указать белый фон, то рамка GroupBox будет видна на PDF под заголовком группы.

      Есть свои особенности в этом подходе, но вроде бы решаемы они.
      +1
      Бесплатность — нам удалось решить одну из важных бизнесс-задач с помощью бесплатных библиотек (MIT)

      На самом деле, это не бесплатный подход, потому что на решение этой задачи была потрачена Ваша оплата. Бывают случаи, когда дешевле купить библиотеку, чем оплачивать работу программиста.

      А так, очень круто, что Вы реализовали это бесплатно. Работа с PDF — тот еще геморрой.
        0
        На самом деле, это не бесплатный подход, потому что на решение этой задачи была потрачена Ваша оплата.

        Ну наше время уже оплачено заранее. Что на реализацию с бесплатной либой, что на изучение и реализацию с платной (напр., Telerik Reporting) времени нашего потребовалось, возможно, одинаково. В дальнесрочной перспективе, впрочем, трудно оценить.
        0
        Спасибо, пригодится.

        Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

        Самое читаемое