Создание и обработка шаблонов печатных форм

в 10:47, , рубрики: Aspose.NET, word, Программирование, метки: , ,

Как показала практика работы с ERP системами — корпоративные приложения на 30% состоят из отчетов. Типичная ситуация для таких приложений — построить отчет по некоторым данным.

Для построения отчетов можно использовать ReportBuilder или любые другие системы построения отчетов. В этой статье я хочу рассмотреть построение отчетов в MS Word 2003 (и более поздние версии) посредством Aspose.Words, так как легко вносить правки, удобно разрабатывать, не требует особых навыков по работе с гигантами систем отчетов и т.д.

По однотипному набору данных требуется создавать шаблоны отчетов, которые представляют из себя обычный документ Word 2003 с набором ключевых полей.
Потом можно настроить шаблон как угодно, то есть привести его к тому виду в котором он должен выглядеть в конечном варианте. То есть создаем шаблон печатных форм для выбранного набора данных.

Плюсом такого решения является то, что человек, занимающийся разработкой самой печатной формы, может не иметь никаких знаний о SQL или источниках данных. Он должен лишь уметь работать с MS Office Word. Казалось бы, все просто, но в нашем случае предполагается, что:

  • создание шаблона отчета и самого отчета может происходить на компьютере, где не установлен MS Office. Это отсекает возможность использовать COM;
  • шаблоны отчетов имеют формат doc, а не docx, что было бы довольно удобно;

Постановка задачи

  • На сервере где крутится продукт — не должен быть установлен Ms Word и иже с ним
  • Это должен быть именно Word 2003, спасибо что не 95
  • Удобное, а главное быстрое решение задачи
  • Шаблоны отчетов должны быть настолько просты, что их мог бы создавать конечный бизнес-пользователь

Итак, задача ясна, приступаем.

Основными элементами печатных форм являются обычные поля с данными и таблицы данных. Для удобства работы разделим создание набора полей в шаблоне и создание таблицы в отдельные методы.

/// <summary> Создать шаблон с набором данных </summary>
/// <param name="additionalFields">
/// Ключи - системные имена полей, значения - наименования на форме
/// Поля для добавления (Без учета таблиц)
/// </param>
/// <returns> Поток </returns>
public static MemoryStream CreateNewTemplate(Dictionary<string, string> additionalFields)
{
    var doc = new Document();
    var docBuilder = new DocumentBuilder(doc);
    docBuilder.MoveToDocumentEnd();//Перемещает не в конец документа, а в конец его значимой части
    foreach (var field in additionalFields)
    {
         docBuilder.Font.Bold = true;
         docBuilder.InsertHtml(string.Format("{0}: ", field.Value));
         docBuilder.Font.Bold = false;
         //Вставка специального поля, первое значение - код поля, второе - наименование, которое видит человек, открывший документ Word
         docBuilder.InsertField(string.Format(@"MERGEFIELD {0} * MERGEFORMAT", field.Key), field.Key);
         docBuilder.InsertHtml("<br>");
    }

    var stream = new MemoryStream();
    doc.Save(stream, SaveFormat.Doc); //Куда сохраняем и в каком формате
    stream.Position = 0;
    return stream;
}

/// <summary> Добавить поля для таблицы </summary>
/// <param name="stream"> Исходный поток </param>
/// <param name="tableSysName">Имя таблицы (Должно быть уникальным и без пробелов в названии)</param>
/// <param name="additionalFields">Поля для добавления (Ключи - системные имена полей, Значения - понятные наименования колонок таблицы) </param>
/// <param name="tableRusName">Русское название таблицы</param>
/// <returns> Поток </returns>
public static MemoryStream AddTable(Stream stream, string tableSysName, Dictionary<string, string> additionalFields, string tableRusName)
{
     var doc = new Document(stream);
     var docBuilder = new DocumentBuilder(doc);
     docBuilder.MoveToDocumentEnd();
     docBuilder.Font.Bold = true;
     docBuilder.Font.Size = 14;
     docBuilder.Writeln(tableRusName);
     docBuilder.Font.Size = 12;
     docBuilder.StartTable();
     docBuilder.InsertCell();
     foreach (var field in additionalFields)
     {
         docBuilder.Writeln(field.Value);
         if (field.Key != additionalFields.Last().Key)
         {
              docBuilder.InsertCell();
         }
     }

     docBuilder.EndRow();
     docBuilder.Font.Bold = false;
     docBuilder.InsertCell();
     docBuilder.InsertField(string.Format("MERGEFIELD TableStart:{0}", tableSysName), "{");
     foreach (var field in additionalFields)
     {
          docBuilder.InsertField(string.Format(@"MERGEFIELD {0} * MERGEFORMAT", field.Key), field.Key);
          if (field.Key != additionalFields.Last().Key)
          {
              docBuilder.InsertCell();
           }
     }

     docBuilder.InsertField(string.Format("MERGEFIELD TableEnd:{0}", tableSysName), "}");
     docBuilder.Writeln();
     docBuilder.EndTable();
     docBuilder.Writeln();
     stream.Close();
     var newStream = new MemoryStream();
     doc.Save(newStream, SaveFormat.Doc);
     newStream.Position = 0;
     return newStream;
}

Обратите внимание на строку

docBuilder.InsertField(string.Format("MERGEFIELD TableStart:{0}", tableSysName), "{");

Здесь в таблицу добавляется ключевое поле TableStart с внешним символом «{», этот символ не принципиален, просто внешне занимает мало места на форме. Также в конце добавляется TableEnd с символом «}». Эти поля должны быть в одной строке таблицы. Поля, заключенные между ними, относятся к текущей таблице и будут повторяться для набора данных. То есть, если надо расположить данные на нескольких строках, получите ошибку.

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

Этих двух методов вполне достаточно для создания шаблона. Далее этот шаблон настраивается (шрифты, разметка и прочее) — нас это не сильно интересует. Осталось только заполнить данными готовый шаблон, сохранив при этом разметку. Для заполнения полей, не находящихся в таблице, все просто:

/// <summary> Заполнить шаблон данными </summary>
/// <param name="stream"> Поток файл (Шаблон) </param>
/// <param name="fieldsToFill"> Данные для заполнения (Ключи - системные имена полей, Значения - значения этих полей) </param>
/// <returns> Поток </returns>
public static MemoryStream FillTemplate(Stream stream, Dictionary<string, string> fieldsToFill)
{
     var doc = new Document(stream);
     var firstArray = fieldsToFill.Select(keys => keys.Key).ToArray();
     var secondArray = fieldsToFill.Select(a => (object)a.Value).ToArray();
/*
Значениями должны передаваться object. Рекомендуется передавать текст, так как его предварительное форматирование удобнее делать во время подготовки данных.
Но если вы будете добавлять свой обработчик данных, что можно сделать, имплементировав IFieldMergingCallback, то удобнее работать с объектами.
*/
     doc.MailMerge.Execute(firstArray, secondArray);
     var newStream = new MemoryStream();
     doc.Save(newStream, SaveFormat.Doc);
     newStream.Position = 0;
     stream.Close();
     return newStream;
}

Теперь мы подошли к заполнению таблиц. В Aspose к этому вопросу подошли весьма капитально, в чем Вы скоро убедитесь.

/// <summary>
/// Заполнить таблицу
/// </summary>
/// <param name="stream">Входящий поток</param>
/// <param name="tableSysName">Имя таблицы</param>
/// <param name="tableValues">Значения полей таблицы</param>
/// <returns>Исходящий поток</returns>
public static MemoryStream FillTable(Stream stream, string tableSysName, List<Dictionary<string, string>> tableValues)
{
    var doc = new Document(stream);
    //Вот оно, счастье
    var customersDataSource = new CustomerMailMergeDataSource(tableValues.ToArray(), tableSysName);
    //Принимает в себя экземпляр класса, имплементирующего IMailMergeDataSource, его рассмотрим чуть ниже
    doc.MailMerge.ExecuteWithRegions(customersDataSource);
    var newStream = new MemoryStream();
    doc.Save(newStream, SaveFormat.Doc);
    newStream.Position = 0;
    stream.Close();
    return newStream;
}

Тут все довольно просто, но давайте глянем:

    /// <summary> Данные для заполнения таблицы </summary>
    internal class CustomerMailMergeDataSource : IMailMergeDataSource
    {
        /// <summary> Данные таблицы </summary>
        private readonly Dictionary<string, string>[] tableValue;

        /// <summary> Индекс записи </summary>
        private int recordIndex;

        /// <summary> Конструктор </summary>
        /// <param name="data">Данные для вставки</param>
        /// <param name="tableName">Системное имя таблицы</param>
        public CustomerMailMergeDataSource(Dictionary<string, string>[] data, string tableName)
        {
            tableValue = data;
            recordIndex = -1;
            TableNamePrivate = tableName;
        }

        /// <summary> Имя таблицы </summary>
        public string TableName
        {
            get { return TableNamePrivate; }
        }

        /// <summary> Системное имя таблицы </summary>
        private static string TableNamePrivate { get; set; }

        /// <summary> Конец данных </summary>
        private bool IsEof
        {
            get { return recordIndex >= tableValue.Count(); }
        }

        /// <summary> Aspose.Words вызывает этот метод, чтобы получить значения каждого поля </summary>
        /// <param name="fieldName"> Имя поля </param>
        /// <param name="fieldValue"> Значение для этого поля </param>
        /// <returns> Есть ли данные для указанного поля </returns>
        public bool GetValue(string fieldName, out object fieldValue)
        {
            var containsKey = tableValue[recordIndex].ContainsKey(fieldName);
            fieldValue = containsKey
                             ? tableValue[recordIndex][fieldName]
                             : null;
            return containsKey;
        }

        /// <summary> Стандартный переход к следующему элементу в коллекции </summary>
        /// <returns> Если данных больше нет, то вернет false </returns>
        public bool MoveNext()
        {
            if (!IsEof)
            {
                recordIndex++;
            }

            return !IsEof;
        }

        /// <summary> Получить дочерние данные </summary>
        /// <param name="tableName">Имя таблицы</param>
        /// <returns>Данные для добавления</returns>
        public IMailMergeDataSource GetChildDataSource(string tableName)
        {
            return null;
        }
    }

Вот и все, необходимый минимум есть. Изменения в разметке не происходят, пустые поля (для которых нет данных) просто исчезают в печатной форме. При заполнении полей значениями надо передавать object, но здесь везде передается string. В принципе можно передать любые типы данных, а потом, имплементировав IFieldMergingCallback, обработать и отформатировать данные.

Прикрутить свой обработчик можно, присвоив doc.MailMerge.FieldMergingCallback экземпляр-обработчик. То есть потенциально можно сделать сколько потребуется обработчиков для
всех случаев жизни и всех наборов данных. Чередуя присвоение обработчика с вызовом doc.MailMerge.ExecuteWithRegions(), получим более гибкую обработку.

Возможные вопросы

  1. Это реклама? Нет, это не реклама, просто решил поделиться результатами
  2. Почему именно Aspose.Words? Так сложилось. Под требования подошло наиболее оптимально.
  3. Вот это рабочий вариант? Нет, это упрощенная версия. Можно сказать специальная редакция.На самом деле все немного сложнее.
  4. А где комментарии к коду? Посчитал комментарии в самом коде вполне исчерпывающими.

Вот исходники

Автор: Multysh

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


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