Специальная печать

148

На данный момент, скорее всего, должна быть ясна фундаментальная проблема, связанная с печатью в WPF. Можно воспользоваться быстрыми и "грязными" приемами, описанными в предыдущей статье, и отправить содержимое окна на принтер, при этом даже слегка модифицировав его. Но если нужно построить первоклассное средство печати для приложения, то придется проектировать его самостоятельно.

Печать с помощью классов визуального уровня

Лучший способ сконструировать специальный вывод печати предусматривает работу с классами визуального уровня. Особенно полезны два следующих класса:

ContainerVisual

Усеченный визуальный элемент, который может хранить коллекцию из одного или более объектов Visual (в своей коллекции Children).

DrawingVisual

Унаследованный от ContainerVisual, который добавляет метод RenderOpen() и свойство Drawing. Метод RenderOpen() создает объект DrawingContext, который можно использовать для рисования визуального содержимого (такого как текст, фигуры и т.п.), а свойство Drawing позволяет получить окончательный результат в виде объекта DrawingGroup.

Разобравшись с тем, как использовать эти классы, процесс создания специального вывода печати становится довольно простым:

  1. Создайте DrawingVisual. (В менее общем случае может быть также создан ContainerVisual, в котором скомбинировано более одного рисованного объекта DrawingVisual на одной и той же странице.)

  2. Вызовите DrawingVisual.RenderOpen() для получения объекта DrawingContext.

  3. Воспользуйтесь методами DrawingContext для создания вывода.

  4. Закройте DrawingContext (если DrawingContext заключен в блок using, этот шаг выполнится автоматически).

  5. Воспользуйтесь PrintDialog.PrintVisual() для отправки визуального элемента на принтер.

Этот подход не только обеспечивает более высокую гибкость, чем прием с печатью элемента, который применялся до сих пор, но также потребует меньше накладных расходов.

Очевидно, что ключ к выполнению этой работы состоит в знании методов класса DrawingContext, которые он предлагает для создания вывода. В таблице ниже описаны методы, которые можно использовать. Методы Push...() особенно интересны, т.к. они применяют настройки, которые понадобятся для последующих операций рисования. Метод Pop() может использоваться для отмены последнего вызванного метода Push...(). В случае вызова нескольких методов Push...() отменять их можно по одному последовательными вызовами Pop():

Методы DrawingContext
Наименование Описание
DrawLine(), DrawRectangle(), DrawRoundedRectangle() и DrawEllipse() Рисуют указанную фигуру в заданной позиции, с указанным контуром и заполнением.
DrawGeometry() и DrawDrawing() Рисует более сложные объекты Geometry и Drawing.
DrawText() Рисует текст в указанном месте. Текст, шрифт, заполнение и прочие детали задаются передачей объекта FormattedText этому методу. Если установить свойство FormattedText.MaxTextWidth, можно также использовать DrawText() для рисования текста с переносом.
DrawImage() Рисует растровое изображение в указанной области (определенной Rect).
Pop() Отменяет последний вызванный метод Push...(). Методы Push...() используются для временного применения одного или более эффектов, а метод Pop() — для их отмены.
PushClip() Ограничивает отображение указанной областью. Содержимое, которое выходит за рамки этой области, отсекается.
PushEffect() Применяет BitmapEffect к последующим операциям рисования.
PushOpacity() Применяет новые установки прозрачности, чтобы сделать последующие операции рисования частично прозрачными.
PushTransform() Устанавливает объект Transform, который будет применен к последующим операциям рисования. Трансформацию можно использовать для масштабирования, смещения, поворота или искажения содержимого.

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

PrintDialog printDialog = new PrintDialog();
if (printDialog.ShowDialog() == true)
{
                // Создать визуальный элемент для страницы
                DrawingVisual visual = new DrawingVisual();

                // Получить контекст рисования
                using (DrawingContext dc = visual.RenderOpen())
                {
                    // Определить текст, который необходимо печатать
                    FormattedText text = new FormattedText(txtContent.Text,
                        System.Globalization.CultureInfo.CurrentCulture, 
                        FlowDirection.LeftToRight,
                        new Typeface("Calibri"), 20, Brushes.Black);

                    // Указать максимальную ширину, в пределах которой выполнять перенос текста, 
                    text.MaxTextWidth = printDialog.PrintableAreaWidth / 2;

                    // Получить размер выводимого текста. 
                    Size textSize = new Size(text.Width, text.Height);

                    // Найти верхний левый угол, куда должен быть помещен текст. 
                    double margin = 96 * 0.25;
                    Point point = new Point(
                        (printDialog.PrintableAreaWidth - textSize.Width) / 2 - margin,
                        (printDialog.PrintableAreaHeight - textSize.Height) / 2 - margin);

                    // Нарисовать содержимое, 
                    dc.DrawText(text, point);

                    // Добавить рамку (прямоугольник без фона). 
                    dc.DrawRectangle(null, new Pen(Brushes.Black, 1),
                        new Rect(margin, margin, printDialog.PrintableAreaWidth - margin * 2,
                            printDialog.PrintableAreaHeight - margin * 2));
                }

                // Напечатать визуальный элемент. 
                printDialog.PrintVisual(visual, "Печать с помощью классов визуального уровня"); 
}

На рисунке показан вывод:

Специальный печатный вывод

Для улучшения этого кода, скорее всего, понадобится вынести логику рисования в отдельный класс (возможно, класс документа, служащий оболочкой для печатаемого содержимого). После этого можно будет вызывать метод этого класса для получения визуального элемента и передачи его методу PrintVisual() в обработчике событий внутри кода окна.

Специальная печать с разбиением на страницы

Визуальный элемент не может охватывать несколько страниц. Если нужна многостраничная печать, придется использовать тот же класс, который применялся во время печати FlowDocument, т.е. DocumentPaginator. Отличие в том, что понадобится создать DocumentPaginator самостоятельно. И на этот раз не удастся переложить всю черновую работу на внутренний приватный объект DocumentPaginator.

Реализация базового проектного решения DocumentPaginator достаточно проста. Потребуется добавить метод, разбивающий содержимое на страницы, и как-то внутренне сохранить информацию об этих страницах. Затем необходимо отреагировать на вызов GetPage() для предоставления страницы, нужной PrintDialog. Каждая страница генерируется как DrawingVisual, но DrawingVisual упакован в класс DocumentPage.

Сложность заключается в разбиении содержимого на страницы. И здесь WPF не поможет — придется самостоятельно решать, как именно разбить содержимое. Некоторое содержимое разбить на страницы относительно легко (вроде длинной таблицы, которая будет показана в следующем примере), в то время как другие типы содержимого намного более проблематичны.

Например, для печати длинного текстового документа потребуется пройтись по нему слово за словом, добавляя слова к строкам, а строки — к страницам. Нужно будет измерить каждую отдельную часть текста, чтобы удостовериться, умещается ли она в строку. И это только для разбиения текстового содержимого с использованием обычного выравнивания влево. Чтобы получить нечто подобное выравниванию с наилучшим заполнением, как то, что применяется для FlowDocument, лучше использовать метод PrintDialog.PrintDocument(), как было описано ранее. Это позволит избежать огромного объема кодирования и ряда весьма специализированных алгоритмов.

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

Таблица данных, разбитая на две страницы

В этом примере специальный объект DocumentPaginator содержит код для разнесения данные по страницам и код для печати каждой страницы в объект Visual. Хотя это можно было бы поместить в два класса (например, чтобы позволить некоторым данным печататься тем же образом, но с другой разбивкой на страницы), обычно это не делается, поскольку код, необходимый для вычисления размера страницы, тесно привязан к коду, который действительно печатает эту страницу.

Специальная реализация DocumentPaginator очень длинная, поэтому она приводится фрагмент за фрагментом. Первым делом, StoreDataSetPaginator сохраняет некоторые важные детали в приватных переменных, включая DataTable, которая планируется для печати, и выбранный шрифт, его размер, размер страницы и размеры полей:

public class StoreDataSetPaginator : DocumentPaginator
{
        private DataTable dt;
        private Typeface typeface;
        private double fontSize;
        private double margin;
        
        private Size pageSize;
        public override Size PageSize
        {
            get
            {
                return pageSize;
            }
            set
            {
                pageSize = value;
                PaginateData();
            }
        }

        public StoreDataSetPaginator(DataTable dt, Typeface typeface, double fontSize, double margin, Size pageSize)
        {
            this.dt = dt;
            this.typeface = typeface;
            this.fontSize = fontSize;
            this.margin = margin;
            this.pageSize = pageSize;
            PaginateData();
        }
        
       ... 

Обратите внимание, что эти детали указаны в конструкторе и не могут быть изменены. Единственным исключением является свойство PageSize — обязательное абстрактное свойство класса DocumentPaginator. Можно было бы создать свойства для упаковки других деталей, если нужно позволить коду изменять эти делали после создания средства разбивки на страницы. Просто нужно не забыть вызывать PaginateData(), когда любая из этих деталей изменяется.

PaginateData() — не обязательный член. Это просто удобное место для вычисления необходимого количества страниц. StoreDataSetPaginator разбивает на страницы свои данные, как только DataTable применяется в конструкторе. Когда запускается метод PaginateData(), он измеряет объем необходимого пространства для строки текста и сравнивает его с размером страницы, чтобы определить, сколько строк уместится на каждой странице. Результат сохраняется в поле по имени rowsPerPage:

private int pageCount;
private int rowsPerPage;
private void PaginateData()
{
            // Создать тестовую строку для измерения
            FormattedText text = GetFormattedText("A");

            // Подсчитать строки, которые умещаются на странице
            rowsPerPage = (int)((pageSize.Height-margin*2) / text.Height);

            // Оставить строку для заголовка 
            rowsPerPage -= 1;

            pageCount = (int)Math.Ceiling((double)dt.Rows.Count / rowsPerPage);
}

В этом коде предполагается, что заглавной буквы 'А' будет достаточно для вычисления высоты строки. Однако это может быть не так для всех шрифтов, и в таком случае потребуется передать GetFormattedText() строку, использующую полный список всех символов, цифр и знаков препинания.

Для вычисления количества строк, которые умещаются на странице, применяется свойство FormattedText.Height. Свойство FormattedText.LineHeight, по умолчанию равное 0, не используется. Свойство LineHeight представлено для переопределения расстояния между строками по умолчанию, когда нужно нарисовать блок из нескольких строк текста. Однако если оно не установлено, то класс FormattedText полагается на собственное вычисление, которое использует свойство Height.

В некоторых случаях придется выполнять немного больше работы и сохранять специальный объект для каждой страницы (например, массив строк с текстом для каждой строки). Тем не менее, это не требуется в примере с StoreDataSetPaginator, поскольку все строки одинаковы и нет никакого переноса текста, о котором стоило бы беспокоиться.

PaginateData() использует приватный вспомогательный метод по имени GetFormattedText(). При печати текста обнаружится, что необходимо будет конструировать огромное количество объектов FormattedText. Эти объекты FormattedText будут всегда разделять одну и ту же культуру и опции потока текста слева направо. Во многих случаях они также будут использовать одинаковый шрифт. GetFormattedText() инкапсулирует эти детали и упрощает остальную часть кода. StoreDataPaginator применяет две перегруженные версии GetFormattedText(), одна из которых принимает другой шрифт для использования:

private FormattedText GetFormattedText(string text)
{
     return GetFormattedText(text, typeface);
}

private FormattedText GetFormattedText(string text, Typeface typeface)
{            
    return new FormattedText(
         text, CultureInfo.CurrentCulture, FlowDirection.LeftToRight,
              typeface, fontSize, Brushes.Black);
}

Теперь, имея количество страниц, можно реализовать остальные обязательные свойства DocumentPaginator:

// Всегда возвращает true, потому что количество страниц обновляется 
// немедленно и синхронно, когда изменяется размер страницы. 
// Никогда не находится в неопределенном состоянии
public override bool IsPageCountValid
{
      get { return true; }
}

public override int PageCount
{
      get { return pageCount; }
}

public override IDocumentPaginatorSource Source
{
      get { return null; }
}

He существует фабричного класса, который может создать этот специальный DocumentPaginator, поэтому свойство Source возвращает null.

Последняя деталь реализации является самой длинной. Метод GetPage() возвращает объект DocumentPage для запрошенной страницы, со всеми необходимыми данными. Первый шаг — нахождение позиции, где будут начинаться два столбца. В этом примере размеры столбцов определены относительно ширины одной заглавной буквы "А", что является удобным упрощением, если необходимо обойтись без более детальных вычислений:

public override DocumentPage GetPage(int pageNumber)
{
            // Создать тестовую строку для измерения
            FormattedText text = GetFormattedText("A");

            // Размеры столбцов относительно ширины символа "A"
            double col1_X = margin;
            double col2_X = col1_X + text.Width * 15;
            
            ...

Следующий шаг — поиск смещений, которые идентифицируют диапазон записей, относящихся к этой странице:

 // Вычислить диапазон строк, которые попадают в эту страницу
int minRow = pageNumber * rowsPerPage;
int maxRow = minRow + rowsPerPage;

Теперь можно начать операцию печати. Напечатать понадобится три элемента: заголовки столбцов, строку-разделитель и собственно строки таблицы. Подчеркнутый заголовок рисуется с использованием методов DrawText() и DrawLine() класса DrawingContext. Для вывода строк код проходит в цикле от первой строки до последней, отображая текст из соответствующего объекта DataRow в двух столбцах, а затем увеличивая позицию по координате Y на величину, равную высоте строки текста:

...
   
            // Создать визуальный элемент для страницы
            DrawingVisual visual = new DrawingVisual();

            // Установить позицию в верхний левый угол печатаемой области
            Point point = new Point(margin, margin);

            using (DrawingContext dc = visual.RenderOpen())
            {
                // Нарисовать заголовки столбцов
                Typeface columnHeaderTypeface = new Typeface(typeface.FontFamily, FontStyles.Normal, FontWeights.Bold, FontStretches.Normal);
                point.X = col1_X;
                text = GetFormattedText("Model Number", columnHeaderTypeface);
                dc.DrawText(text, point);
                text = GetFormattedText("Model Name", columnHeaderTypeface);
                point.X = col2_X;
                dc.DrawText(text, point);

                // Нарисовать линию подчеркивания
                dc.DrawLine(new Pen(Brushes.Black, 2),
                    new Point(margin, margin + text.Height),
                    new Point(pageSize.Width - margin, margin + text.Height));
                
                point.Y += text.Height;

                // Нарисовать значения столбцов
                for (int i = minRow; i < maxRow; i++)
                {
                    // Проверить конец последней (частично заполненной) страницы
                    if (i > (dt.Rows.Count - 1)) break;

                    point.X = col1_X;   
                    text = GetFormattedText(dt.Rows[i]["ModelNumber"].ToString());
                    dc.DrawText(text, point);

                    // Добавить второй столбец                    
                    text = GetFormattedText(dt.Rows[i]["ModelName"].ToString());
                    point.X = col2_X;
                    dc.DrawText(text, point);
                    point.Y += text.Height;
                }
            }            
            return new DocumentPage(visual, pageSize, new Rect(pageSize), new Rect(pageSize));
}

Теперь, когда StoreDataSetDocumentPaginator готов, его можно использовать всякий раз, когда понадобится напечатать содержимое DataTable со списком продуктов, как показано ниже:

PrintDialog printDialog = new PrintDialog();
if (printDialog.ShowDialog() == true)
{
                // Вспомогательные действия для извлечения таблицы
                // из XML-файла, используя ADO.NET
                DataSet ds = new DataSet();
                ds.ReadXmlSchema("store.xsd");
                ds.ReadXml("store.xml");

                StoreDataSetPaginator paginator = new StoreDataSetPaginator(ds.Tables[0],
                    new Typeface("Calibri"), 24, 96*0.75,
                    new Size(printDialog.PrintableAreaWidth, printDialog.PrintableAreaHeight));

                printDialog.PrintDocument(paginator, "Печать с помощью классов визуального уровня");
}

Класс StoreDataSetDocumentPaginator обладает определенной встроенной гибкостью. Например, он может работать с различными шрифтами, полями и размерами страниц, однако он не может справиться с данными, имеющими другую схему. Ясно, что в библиотеке WPF еще есть место для удобного класса, который мог бы принимать данные, определения столбцов и строк, заголовки и нижние колонтитулы и т.п., после чего печатать корректно разбитую на страницы таблицу. Пока в WPF нет ничего подобного, но можно ожидать, что независимые поставщики предложат компоненты, которые заполнят этот пробел.

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