Разбиение на страницы при печати в WinRT

75

В общем случае приложение Windows печатает более одной страницы. Число страниц может зависеть от многих факторов: длины документа, размеров шрифтов, размера страницы, полей страницы и ее ориентации (книжная или альбомная).

Обработчик события Paginate не только готовит эти страницы для предварительного просмотра и печати, но и сообщает количество страниц в документе. В некоторых случаях разбивка на страницы требует времени, и в некоторых ситуациях ее можно выполнять поэтапно, и все же проще сделать это за один раз.

Рассмотрим относительно короткий пример разбиения на страницы. Для этого мы вернемся к программе DependencyObjectClassHierarchy из статьи "Элемент ScrollViewer в WinRT" и добавим в нее команду печати. Как вы, возможно, припоминаете, программа DependencyObjectClassHierarchy создавала элемент TextBlock для каждого класса, производного от DependencyObject, и помещала все созданные элементы в панель StackPanel в элементе ScrollViewer. Файл XAML программы PrintableClassHierarchy выглядит так же, как в предыдущей версии:

<Grid Background="#FFF">
        <ScrollViewer>
            <StackPanel Name="stackPanel" />
        </ScrollViewer>
</Grid>

Я решил, что для печати тоже будет использоваться элемент StackPanel, содержащий потомков TextBlock, но с одним принципиальным различием: для вывода на экран использовалась только одна панель StackPanel, потому что она находилась в ScrollViewer. Для печати придется создать для каждой страницы панель StackPanel, в которой содержатся элементы TextBlock только этой страницы.

Возникает соблазнительная мысль: использовать одни и те же элементы TextBlock для экрана и принтера. Теоретически можно выводить на печать элементы, уже отображаемые на экране, но по моему опыту этот метод никогда не работает так хорошо, как хотелось бы. Главное ограничение состоит в том, что элемент не может иметь двух родителей. В приведенном примере печатаемый элемент TextBlock должен быть потомком StackPanel для печатной страницы, поэтому он не может одновременно быть потомком StackPanel на экране.

По этой причине переработанная версия программы иерархии классов создает отдельную коллекцию элементов TextBlock, которая хранится в поле с именем printerTextBlocks. Эта часть класса MainPage очень похожа на файл фонового кода из предыдущей программы, не считая того, что код создания TextBlock был выделен в отдельный метод для удобства создания двух параллельных наборов элементов TextBlock. Обратите внимание на явное назначение элементам TextBlock для принтера черного основного цвета (Foreground) в методе DisplayAndPrinterPrep (который ранее назывался Display). Большая часть поддержки печати отсутствует в следующем фрагменте:

using System;
using System.Collections.Generic;
using System.Reflection;
using Windows.Foundation;
using Windows.Graphics.Printing;
using Windows.UI;
using Windows.UI.ViewManagement;
using Windows.UI.Xaml;
using Windows.UI.Xaml.Controls;
using Windows.UI.Xaml.Documents;
using Windows.UI.Xaml.Media;
using Windows.UI.Xaml.Navigation;
using Windows.UI.Xaml.Printing;

namespace PrintableClassHierarchy
{
    public sealed partial class MainPage : Page
    {
        List<Type> classes = new List<Type>();
        Brush highlightBrush;
        Type rootType = typeof(DependencyObject);
        TypeInfo rootTypeInfo = typeof(DependencyObject).GetTypeInfo();

        // Поддержка печати
        List<TextBlock> printerTextBlocks = new List<TextBlock>();
        Brush blackBrush = new SolidColorBrush(Colors.Black);

        // ...

        public MainPage()
        {
            this.InitializeComponent();
            highlightBrush =
                new SolidColorBrush(new UISettings().UIElementColor(UIElementType.Highlight));

            // Накопление всех классов, производных от DependencyObject 
            AddToClassList(typeof(Windows.UI.Xaml.DependencyObject));

            // Алфавитная сортировка по имени
            classes.Sort((t1, t2) =>
            {
                return String.Compare(t1.GetTypeInfo().Name, t2.GetTypeInfo().Name);
            });

            // Организация отсортированных классов в древовидную структуру
            ClassAndSubclasses rootClass = new ClassAndSubclasses(rootType);
            AddToTree(rootClass, classes);

            // Отображение дерева с использованием элементов TextBlock, 
            // помещенных в StackPanel
            DisplayAndPrinterPrep(rootClass, 0);

            // ...
        }

        // ...

        private void AddToClassList(Type sampleType)
        {
            Assembly assembly = sampleType.GetTypeInfo().Assembly;

            foreach (Type type in assembly.ExportedTypes)
            {
                TypeInfo typeInfo = type.GetTypeInfo();

                if (typeInfo.IsPublic && rootTypeInfo.IsAssignableFrom(typeInfo))
                    classes.Add(type);
            }
        }

        private void AddToTree(ClassAndSubclasses parentClass, List<Type> classes)
        {
            foreach (Type type in classes)
            {
                Type baseType = type.GetTypeInfo().BaseType;

                if (baseType == parentClass.Type)
                {
                    ClassAndSubclasses subClass = new ClassAndSubclasses(type);
                    parentClass.Subclasses.Add(subClass);
                    AddToTree(subClass, classes);
                }
            }
        }

        private void DisplayAndPrinterPrep(ClassAndSubclasses parentClass, int indent)
        {
            TypeInfo typeInfo = parentClass.Type.GetTypeInfo();

            // Создание элемента TextBlock и добавление его в StackPanel
            TextBlock txtblk = CreateTextBlock(typeInfo, indent);
            stackPanel.Children.Add(txtblk);

            // Создание элемента TextBlock и добавление его в список печати
            txtblk = CreateTextBlock(typeInfo, indent);
            txtblk.Foreground = blackBrush;
            printerTextBlocks.Add(txtblk);

            // Рекурсивный вызов метода для всех подклассов
            foreach (ClassAndSubclasses subclass in parentClass.Subclasses)
                DisplayAndPrinterPrep(subclass, indent + 1);
        }

        private TextBlock CreateTextBlock(TypeInfo typeInfo, int indent)
        {
            // Создание элемента TextBlock с именем типа
            TextBlock txtblk = new TextBlock();
            txtblk.Inlines.Add(new Run { Text = new string(' ', 8 * indent) });
            txtblk.Inlines.Add(new Run { Text = typeInfo.Name });

            // Определение того, является ли класс запечатанным (sealed)
            if (typeInfo.IsSealed)
                txtblk.Inlines.Add(new Run
                {
                    Text = " (sealed)",
                    Foreground = highlightBrush
                });

            // Проверка возможности создания экземпляров класса
            IEnumerable<ConstructorInfo> constructorInfos = typeInfo.DeclaredConstructors;
            int publicConstructorCount = 0;

            foreach (ConstructorInfo constructorInfo in constructorInfos)
                if (constructorInfo.IsPublic)
                    publicConstructorCount += 1;

            if (publicConstructorCount == 0)
                txtblk.Inlines.Add(new Run
                {
                    Text = " (без возможности создания экземпляров)",
                    Foreground = highlightBrush
                });

            return txtblk;
        }

        // ...
    }
}

Остальной код поддержки печати очень похож на то, что вы видели ранее (не считая того, что на печать выводится не одна страница, а несколько). Метод Paginate берет на себя основную тяжесть работы и сохраняет отформатированные страницы в поле printerPages. Каждый из объектов представляет собой элемент Border со свойством Padding, равным 96 (один дюйм), и дочерним элементом StackPanel со страничной порцией элементов TextBlock, созданных ранее.

Помните, что обработчик Paginate может вызываться многократно при переключении между книжной и альбомной ориентацией или разными размерами бумаги. Так как программа работает с фиксированной коллекцией элементов TextBlock, а элементы не могут иметь нескольких родителей, метод Paginate в самом начале работы должен убедиться в том, что ни один элемент TextBlock не остается потомком ранее созданного элемента StackPanel:

// ...

namespace PrintableClassHierarchy
{
    public sealed partial class MainPage : Page
    {
        // ...

        PrintDocument printDocument;
        IPrintDocumentSource printDocumentSource;
        List<UIElement> printerPages = new List<UIElement>();

        public MainPage()
        {
            // ...

            // Создание объекта PrintDocument и добавление обработчиков
            printDocument = new PrintDocument();
            printDocumentSource = printDocument.DocumentSource;
            printDocument.Paginate += OnPrintDocumentPaginate;
            printDocument.GetPreviewPage += OnPrintDocumentGetPreviewPage;
            printDocument.AddPages += OnPrintDocumentAddPages;
        }

        protected override void OnNavigatedTo(NavigationEventArgs e)
        {
            // Прикрепить обработчик PrintManager
            PrintManager.GetForCurrentView().PrintTaskRequested 
                += OnPrintManagerPrintTaskRequested;

            base.OnNavigatedTo(e);
        }

        protected override void OnNavigatedFrom(NavigationEventArgs e)
        {
            // Отсоединить обработчик PrintManager
            PrintManager.GetForCurrentView().PrintTaskRequested 
                -= OnPrintManagerPrintTaskRequested;

            base.OnNavigatedFrom(e);
        }

        // ...

        private void OnPrintManagerPrintTaskRequested(PrintManager sender, 
            PrintTaskRequestedEventArgs e)
        {
            e.Request.CreatePrintTask("Иерархия свойств зависимости", (requestArgs) =>
            {
                requestArgs.SetSource(printDocumentSource);
            });
        }

        private void OnPrintDocumentPaginate(object sender, PaginateEventArgs e)
        {
            // Переменные для полей страницы
            double leftMargin = 96;
            double topMargin = 96;
            double rightMargin = 96;
            double bottomMargin = 96;

            // Очистка предыдущей коллекции printerPage
            foreach (UIElement printerPage in printerPages)
                ((printerPage as Border).Child as Panel).Children.Clear();

            printerPages.Clear();

            // Инициализация для конструирования страницы
            Border border = null;
            StackPanel stackPanel = null;
            double maxPageHeight = 0;
            double pageHeight = 0;

            // Поиск по списку элементов TextBlock
            for (int index = 0; index < printerTextBlocks.Count; index++)
            {
                // Объект Border равный null, обозначает новую страницу
                if (border == null)
                {
                    // Вычисление высоты, доступной для текста
                    uint pageNumber = (uint)printerPages.Count;
                    maxPageHeight = 
                        e.PrintTaskOptions.GetPageDescription(pageNumber).PageSize.Height;
                    maxPageHeight -= topMargin + bottomMargin;
                    pageHeight = 0;

                    // Создание StackPanel и Border
                    stackPanel = new StackPanel();
                    border = new Border
                    {
                        Padding = new Thickness(leftMargin, topMargin, rightMargin, bottomMargin),
                        Child = stackPanel
                    };

                    // Добавление в список страниц
                    printerPages.Add(border);
                }

                // Получение TextBlock и определение размеров

                TextBlock txtblk = printerTextBlocks[index];
                txtblk.Measure(Size.Empty);

                // Проверяем, можно ли добавить TextBlock в страницу
                if (stackPanel.Children.Count == 0 ||
                    pageHeight + txtblk.ActualHeight < maxPageHeight)
                {
                    stackPanel.Children.Add(txtblk);
                    pageHeight += Math.Ceiling(txtblk.ActualHeight);
                }
                // В противном случае конец страницы
                else
                {
                    // Больше не работаем с этим объектом Border
                    border = null;

                    // Повторная обработка TextBlock
                    index--;
                }
            }

            // Оповещение об окончательном количестве страниц
            printDocument.SetPreviewPageCount(printerPages.Count, 
                PreviewPageCountType.Final);
        }

        private void OnPrintDocumentGetPreviewPage(object sender, GetPreviewPageEventArgs e)
        {
            printDocument.SetPreviewPage(e.PageNumber, printerPages[e.PageNumber - 1]);
        }

        private void OnPrintDocumentAddPages(object sender, AddPagesEventArgs e)
        {
            foreach (UIElement printerPage in printerPages)
                printDocument.AddPage(printerPage);

            printDocument.AddPagesComplete();
        }
    }
}

Стратегия разбиения на страницы основана на вычислении значения maxPageHeight по высоте бумажного листа за вычетом дюймовых полей сверху и снизу. Другая переменная с именем pageHeight увеличивается для каждого элемента TextBlock, добавленного в контейнер StackPanel для этой страницы. Метод вызывает метод Measure для каждого элемента TextBlock с целью вычисления его размера, и если высота TextBlock в сумме с pageHeight превышает maxPageHeight, необходима новая страница.

Обработчик GetPreviewPage использует свойство PageNumber аргументов события для обращения к соответствующему элементу списка printerPages. Обработчик AddPages вызывает AddPage для всех страниц.

В режиме предварительного просмотра можно просмотреть разные страницы перед выводом всего списка на печать:

Просмотр страниц перед печатью

Возможно, вы заметили, что логика разбиения на страницы увеличивает pageHeight в зависимости от высоты каждого элемента TextBlock следующей командой:

pageHeight += Math.Ceiling(txtblk.ActualHeight);

Сначала я не использовал метод Math.Ceiling. По умолчанию свойство FontSize равно 11, а свойство ActualHeight возвращало значение 13.2, при котором моя программа выделяла каждой панели StackPanel 65 строк текста для вывода на 9 дюймах, доступных в книжном режиме. Однако в режиме предварительного просмотра (а также на фактической печатной странице) были видны только 62 строки. Очевидно, межстрочные интервалы, используемые для размещения текста в StackPanel, были больше 13.2. В результате три элемента TextBlock на страницу терялись, потому что размеры элемента StackPanel превышали выделенное для него место.

С вызовом Math.Ceiling в этом случае выводилась 61 строка текста на страницу; возможно, это перегиб в другом направлении, но по крайней мере никакой текст не терялся.

И все же это немного странно. Конечно, на мониторах выравнивание текста по границам пикселов имеет смысл по соображениям удобочитаемости, поэтому координаты округляются. Но на принтере на дюйм приходится 600 пикселов (или около того), так что брать за основу округления устройство с разрешением 96 DPI не обязательно.

Разбиение на страницы может быть очень сложным процессом, особенно при использовании текста. Если у вас хронически возникают проблемы с элементами, которые не желают размещаться в нужном месте на странице печати, попробуйте переключиться на панель Canvas. Для сложных текстовых макетов вместо TextBlock часто используются элементы Glyphs, а если вы зайдете в тупик и с Glyphs - изучите возможности применения Direct Write для вывода как на экран, так и на печатную страницу.

Если вы решите пойти в другом направлении - шире использовать Windows Runtime для определения того, как должен отображаться текст, - возможно, вам пригодится элемент RichTextBlock.

Пройди тесты
Лучший чат для C# программистов