Как стать автором
Обновить
68.99
БАРС Груп
Цифровые решения для роста качества жизни людей

Разрабатываем печать документов на .NET с помощью OpenXml. Часть 1

Уровень сложностиСредний
Время на прочтение10 мин
Количество просмотров1.6K

В жизни многих программных проектов наступает момент реализации требования о функциональности печати. Пользователям системы часто нужно получить свои бизнес-данные в файле одного из привычных форматов (.docx/.xlsx/.pdf, нужное подчеркнуть), чтобы дальше этот файл распечатать, отправить на согласование, передать в интегрируемые системы, или всё вместе. Иногда — и мы в своих проектах с этим сталкивались — для пользователя отображение данных в документе даже важнее, чем на экране в приложении, и, как следствие, внимание к правильности данных при печати в документ более пристальное, чем при выводе в UI. Структура документа в таких случаях, как правило, регламентирована некоторым шаблоном.

Так какими же инструментами воспользоваться, чтобы покрыть требования печати документов?

Как показывает практика, рынок инструментов генерации документов состоит преимущественно из проприетарных приложений, использование которых может затрудняться политикой вашего проекта в отношении лицензий на сторонние компоненты. Кроме того, иногда возникают ограничения на страну происхождения компонента или на ОС, в которой тот работает. И тут возникает идея разработать печать документов самостоятельно…

Всем привет! Я Александр Родов, ведущий разработчик в компании «БАРС Груп», автор и руководитель разработки сервиса генерации печатных форм Sprinter. В этой статье я хочу поделиться опытом формирования docx-документов на .NET с помощью opensource-библиотеки DocumentFormat.OpenXml. Замечу, что базовые примеры генерации документов доступны на сайте Microsoft, однако, по моему опыту, при детальной разработке достаточно сложного печатного документа, к сожалению, вопросов документация оставляет больше, чем дает ответов.

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

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

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

В текущей статье рассмотрим печать основной информации о заказе и вставку логотипа магазина в колонтитул документа. Печать таблицы рассмотрим в следующем материале.

Модель данных

Сформируем модель данных в соответствии с постановкой задачи, код класса PurchaseOrder приведен ниже.

/// <summary> Заказ </summary>
public class PurchaseOrder
{
    /// <summary> Имя клиента </summary>
    public string CustomerName { get; set; }
    
    /// <summary> Дата заказа </summary>
    public DateTime PurchaseDate { get; set; }
    
    /// <summary> Номер заказа </summary>
    public string PurchaseNumber { get; set; }
    
    /// <summary> Адрес доставки </summary>
    public string CustomerAddress { get; set; }
    
    /// <summary> Состав заказа </summary>
    public PurchasePosition[] Items { get; set; }
}

/// <summary> Позиция заказа </summary>
public class PurchasePosition
{
    /// <summary> Наименование товара </summary>
    public string ProductName { get; set; }
    
    /// <summary> Код товара </summary>
    public string ProductCode { get; set; }
    
    /// <summary> Цена за единицу товара </summary>
    public decimal UnitPrice { get; set; }
    
    /// <summary> Количество товара в заказе </summary>
    public int Count { get; set; }
}

Создание и сохранение пустого документа

Docx-файл представляет собой zip-архив с набором каталогов и xml-файлов, описывающих разные разделы документа и его содержимое. Большая часть разделов являются необязательными и добавляются только при необходимости использования той или иной функции (например, вставка фигур или изображений). Для создания пустого docx-файла нам нужно инициализировать обязательные части документа. Ниже представлен соответствующий блок кода.

public async Task<MemoryStream> Print(PurchaseOrder order)
{
    var stream = new MemoryStream();
    using var document = InitDocument(stream);
    
    // PrintTitle(document, order);
    // PrintPurchasesTable(document, order);
    // PrintHeader(document, order);
    
    document.Save();

    return stream;
}

private WordprocessingDocument InitDocument(MemoryStream docStream)
{
    var document = WordprocessingDocument.Create(docStream, WordprocessingDocumentType.Document, autoSave: true);
    
    var mainPart = document.AddMainDocumentPart();
    
    new Document(new Body()).Save(mainPart);
    
    // GenerateStyles(document);

    return document;
}

Внутренняя структура пустого docx (zip) файла показана на скриншоте:

Здесь можно отметить, что каждый файл в структуре архива описывается объектом *Part, например, файл document.xml добавлен путём инициализации свойства document.MainDocumentPart. Возможность открыть готовый docx-документ как zip-архив и посмотреть структуру документа в xml очень удобна при отладке разрабатываемой печати docx.

Вывод текстовых данных

Текст в структуре docx разбивается на иерархически вложенные объекты Paragraph => Run => Text. Paragraph описывает абзац в документе, и может включать не только текстовые, но и прочие элементы, например фигуры и изображения. Run описывает элемент, входящий в состав параграфа, в рамках которого локально может изменяться тип элемента и настройки стилей. Наконец, Text – конечный элемент, содержащий непосредственно наш текст.

Некоторые настройки текста, например выравнивание и межстрочный интервал, настраиваются для всего параграфа, другие же могут меняться для отдельных элементов Run: размер шрифта, жирный/подчёркнутый/зачёркнутый текст и т.д. 

Рассмотрим пример с формированием заголовка документа, содержащего различные форматы текста для выделения номера и даты заказа.

var paragraphProperties = new ParagraphProperties
{
    ParagraphStyleId = new ParagraphStyleId { Val = TitleStyleId },
    Justification = new Justification { Val = JustificationValues.Center },
    SpacingBetweenLines = new SpacingBetweenLines
    {
        After = "0",
        Line = "360",
        LineRule = LineSpacingRuleValues.Auto
    }
};

var titleParagraph = new Paragraph
{
    ParagraphProperties = (ParagraphProperties)paragraphProperties.CloneNode(true)
};

titleParagraph.Append(
    new Run(new Text("Заказ №") { Space = SpaceProcessingModeValues.Preserve }),
    new Run(new Text($"{data.PurchaseNumber}") { Space = SpaceProcessingModeValues.Preserve })
    {
        RunProperties = new RunProperties
        {
            Underline = new Underline { Val = UnderlineValues.Single }
        }
    },
    new Run(new Text($" от {data.PurchaseDate.ToLongDateString()}") { Space = SpaceProcessingModeValues.Preserve })
    {
        RunProperties = new RunProperties
        {
            Italic = new Italic { Val = true }
        }
    });

document.MainDocumentPart.Document.Body.Append(titleParagraph);

Здесь нужно отметить несколько моментов:

  • Объект ParagraphProperties копируется глубоким клонированием перед присваиванием параграфу. Это делается для возможности переиспользования заданных в объекте настроек в следующих параграфах, и здесь мы имеем дело с важным требованием всей библиотеки в части составления документа: никакие экземпляры объектов в документе не должны использоваться дважды, повторное добавление элемента в документ вызовет ошибку. Поэтому каждый переиспользуемый фрагмент настроек следует клонировать перед вставкой.

  • В ParagraphProperties указывается значение ParagraphStyleId – идентификатор общего стиля документа. Задание общих стилей описывается в следующем разделе статьи.

  • Отдельные части текста могут быть индивидуально отформатированы с помощью переопределения настроек стилей в объектах RunProperties.

  • Элементы добавляются в тело документа последовательно, в порядке вывода на листе.

Остальная часть текста документа добавляется аналогичным образом. Код вставки текста будет следующим:

// Для следующих параграфов переопределим выравнивание текста
paragraphProperties.Justification = new Justification { Val = JustificationValues.Left };
var runProperties = new RunProperties { FontSize = new FontSize { Val = "28" }, Bold = new Bold { Val = false } };

document.MainDocumentPart.Document.Body.Append(new Paragraph(
    new Run(new Text($"Клиент: {data.CustomerName}") { Space = SpaceProcessingModeValues.Preserve })
    {
        RunProperties = runProperties,
    })
{
    ParagraphProperties = (ParagraphProperties)paragraphProperties.CloneNode(true)
});

document.MainDocumentPart.Document.Body.Append(new Paragraph(
    new Run(new Text($"Адрес доставки: {data.CustomerAddress}") { Space = SpaceProcessingModeValues.Preserve })
    {
        RunProperties = (RunProperties)runProperties.CloneNode(true)
    })
{
    ParagraphProperties = (ParagraphProperties)paragraphProperties.CloneNode(true)
});

Добавление стилей

Стили удобны для переиспользования настроек форматирования текста в пределах всего документа. Они настраиваются в разделе StyleDefinitionsPart, что соответствует файлу Styles.xml в zip-структуре документа. Для назначения стиля текстовому элементу используется текстовый идентификатор стиля. Код добавления стилей в документ приведён далее.

private const string DefaultColor = "0b166d";
    
    private const string FontFamily = "Carlito";

    private const string TitleStyleId = "title_style";
    private const string TableStyleId = "table_style";

private void GenerateStyles(WordprocessingDocument document)
{
    var stylesPart = document.MainDocumentPart!.AddNewPart<StyleDefinitionsPart>();
    var styles = new Styles();
    
    styles.Append(BuildStyle(TitleStyleId, "Заголовок", 16, true, DefaultColor));
    styles.Append(BuildStyle(TableStyleId, "Таблица", 12, false, DefaultColor));

    styles.Save(stylesPart);
    
    Style BuildStyle(string id, string name, int fontSizePt, bool bold, string color)
    {
        return new Style
        {
            StyleId = id,
            StyleName = new StyleName { Val = name },
            Type = StyleValues.Paragraph,
            CustomStyle = true,
            BasedOn = new BasedOn
            {
                Val = "Normal"
            },
            NextParagraphStyle = new NextParagraphStyle
            {
                Val = "Normal"
            },
            StyleRunProperties = new StyleRunProperties
            {
                FontSize = new FontSize { Val = (fontSizePt * 2).ToString() },
                Bold = new Bold { Val = bold },
                RunFonts = new RunFonts
                {
                    Ascii = FontFamily,
                    ComplexScript = FontFamily,
                    EastAsia = FontFamily,
                    HighAnsi = FontFamily
                },
                Color = new Color { Val = color }
            }
        };
    }
}

Работа с колонтитулами и вставка изображений

Колонтитулы в docx-документе описываются в отдельных разделах HeaderPart, и затем ссылки на них указываются в теле документа. Документ может разбиваться на разделы, и каждому разделу может быть назначен свой колонтитул, верхний и нижний. Код вставки верхнего колонтитула:

private const string HeaderId = "rIdh1";
    
private void PrintHeader(WordprocessingDocument document, PurchaseOrder data)
{
    var headerPart = document.MainDocumentPart!.AddNewPart<HeaderPart>(HeaderId);
    headerPart.Header = new Header();
    headerPart.Header.AddNamespaceDeclaration("w", "http://schemas.openxmlformats.org/wordprocessingml/2006/main");
    headerPart.Header.Append(BuildLogo(headerPart));

    document.MainDocumentPart.Document.Body.Append(new SectionProperties(new PageSize
        {
            Orient = PageOrientationValues.Portrait,
            Width = (uint)CmToTwip(21f),
            Height = (uint)CmToTwip(29.7f)
        },
        new PageMargin
        {
            Top = CmToTwip(2),
            Right = (uint)CmToTwip(2),
            Bottom = CmToTwip(1.5f),
            Left = (uint)CmToTwip(2)
        },
        new PageNumberType { Start = 1 },
        new HeaderReference
        {
            Type = HeaderFooterValues.Default,
            Id = document.MainDocumentPart.GetIdOfPart(headerPart)
        }));
}

private int CmToTwip(float cmSize)
{
    return Convert.ToInt32(Math.Ceiling(cmSize / 2.54f * 72 * 20));
}

Отметим здесь несколько важных моментов:

  • В конец тела документа вставляется объект SectionProperties, в котором помимо упомянутой ссылки на верхний колонтитул (HeaderReference) указывается также формат листа документа (21 см на 29.7 см, что соответствует формату А4), ориентация листа – портретная, а также поля на листе — PageMargin.

  • Почти все размеры в документе docx указываются в единицах TWIP – TwentIeth Point, или одна двадцатая пикселя. Конвертация сантиметров в TWIP реализована в приведенном методе CmToTwip со значением dpi=72.

  • Корневым объектом верхнего колонтитула является объект Header, в который вставляется логотип магазина. Код вставки изображения в документ приведен ниже.

private Paragraph BuildLogo(HeaderPart headerPart)
{
    int CmToEmu(float cmValue)
    {
        return Convert.ToInt32(Math.Ceiling(cmValue * 360000));
    }

    var imagePart = headerPart.AddImagePart(ImagePartType.Png);
    using (var memoryStream = new MemoryStream(File.ReadAllBytes("logo.png")))
    {
        imagePart.FeedData(memoryStream);
    }
    
    var imageRelId = headerPart.GetIdOfPart(imagePart);
    
    var sizes = (w: CmToEmu(4.8f), h: CmToEmu(3f));
    var offset = (x: CmToEmu(6.1f), y: 0);

    var imgId = 1u;
    var imgName = $"Изображение{imgId}";
    
    var nvPicPr = new NonVisualPictureProperties(new NonVisualDrawingProperties
        {
            Id = imgId,
            Name = imgName
        },
        new NonVisualPictureDrawingProperties
        {
            PictureLocks = new PictureLocks
            {
                NoChangeAspect = true,
                NoChangeArrowheads = true
            }
        });

    var blipFill = new BlipFill(new Stretch(new FillRectangle()))
    {
        Blip = new Blip
        {
            Embed = imageRelId
        }
    };

    var spPr = new ShapeProperties(new TransformGroup
        {
            Offset = new Offset
            {
                X = offset.x,
                Y = offset.y
            },
            Rotation = 0,
            Extents = new Extents { Cx = sizes.w, Cy = sizes.h }
        },
        new PresetGeometry { Preset = ShapeTypeValues.Rectangle });
    
    var pic = new Picture(nvPicPr, blipFill, spPr);
    
    var graphic = new Graphic
    {
        GraphicData = new GraphicData(pic)
        {
            Uri = "http://schemas.openxmlformats.org/drawingml/2006/picture"
        }
    };
    var inline = new Inline(
        new DocProperties { Id = imgId, Name = imgName },
        graphic)
    {
        DistanceFromTop = 0,
        DistanceFromBottom = 0,
        DistanceFromLeft = 0,
        DistanceFromRight = 0,
        Extent = new Extent
        {
            Cx = sizes.w + offset.x,
            Cy = sizes.h + offset.y,
        },
        EffectExtent = new EffectExtent
        {
            TopEdge = 0,
            BottomEdge = 0,
            RightEdge = 0,
            LeftEdge = 0
        }
    };

    var drawing = new Drawing(inline);
    
    var run = new Run(drawing) { RunProperties = new RunProperties() };
    return new Paragraph(run)
    {
        ParagraphProperties = new ParagraphProperties
        {
            Justification = new Justification
            {
                Val = JustificationValues.Left
            }
        }
    };
}

Изображение добавляется в документ отдельным файлом в разделе media, этому файлу соответствует объект ImagePart. Параметры вставки изображения описываются объектом Drawing, который также вставляется в параграф, аналогично текстовому элементу. Отметим также, что, в отличие от размеров полей и листа, задаваемых в единицах TWIP, размеры графических элементов в docx хранятся в единице EMU, равной 1/360000 доле сантиметра.

Заключение

В первой части статьи мы рассмотрели формирование шапки документа заказа в интернет-магазине. Получившаяся у нас часть документа имеет вид:

Внутренняя же структура получившегося zip-файла получилась следующая:

В следующей части нашей серии статей о генерации документов «офисных» форматов мы поработаем с таблицами в docx и сформируем данные о составе заказа.


Мы в «БАРС Груп» разрабатываем цифровые решения для государства, бизнеса и людей. Принимаем активное участие в реализации Национального проекта «Цифровая экономика» и создаем цифровые решения для импортозамещения программного обеспечения — 88 решений компании зарегистрировано в реестре российского ПО. Рассказываем о наших продуктах и ИТ-трендах в Telegram-канале. Сервис печати Sprinter — наш новый продукт, так же уже входит в реестр. Он помогает разработчикам и аналитикам с печатью документов по заданным шаблонам. А ещё благодаря ему увидела свет эта статья!

Теги:
Хабы:
Всего голосов 3: ↑3 и ↓0+3
Комментарии2

Публикации

Информация

Сайт
bars.group
Дата регистрации
Дата основания
Численность
1 001–5 000 человек
Местоположение
Россия