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

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

Большинство столбцов таблицы содержат готовые данные из заказа. Столбец F (Стоимость) является расчётным: значения в ячейках равны произведению количества и стоимости за единицу товара. Последняя строка таблицы содержит две расчётные ячейки в столбцах D и F — суммы значений в этих столбцах. Также нам необходимо будет применить настройки границ таблицы, размеров столбцов, объединения ячеек и форматов значений в ячейках.
Создание пустого документа
Как и в случае с docx, xlsx-файл содержит множество разделов (Parts), отвечающих за определённую часть содержимого документа, например, стили, медиафайлы, и т.д. Некоторые из этих разделов являются обязательными для корректного открытия документа, другие же опциональны. Для нашего документа мы заполним базовую структуру, как показано ниже:
private const int IntFormatId = 101;
private const int DecimalFormatId = 102;
private const int DateFormatId = 103;
public async Task<MemoryStream> Print(PurchaseOrder order)
{
var stream = new MemoryStream();
using var document = InitDocument(stream, order);
PrintPurchase(document, order);
document.Save();
return stream;
}
private SpreadsheetDocument InitDocument(MemoryStream stream, PurchaseOrder order)
{
var doc = SpreadsheetDocument.Create(stream, SpreadsheetDocumentType.Workbook, true);
var workbookPart = doc.AddWorkbookPart();
workbookPart.Workbook = new Workbook();
#region раздел стилей
var stylesPart = workbookPart.AddNewPart<WorkbookStylesPart>();
stylesPart.Stylesheet = new Stylesheet
{
Fonts = new Fonts { Count = 1 },
Fills = new Fills { Count = 2 },
Borders = new Borders { Count = 1 },
CellFormats = new CellFormats { Count = 1 }
};
stylesPart.Stylesheet.Fonts.AppendChild(new Font());
stylesPart.Stylesheet.Fills.AppendChild(new Fill { PatternFill = new PatternFill { PatternType = PatternValues.None } });
stylesPart.Stylesheet.Fills.AppendChild(new Fill { PatternFill = new PatternFill { PatternType = PatternValues.Gray125 } });
stylesPart.Stylesheet.Borders.AppendChild(new Border());
stylesPart.Stylesheet.CellFormats.AppendChild(new CellFormat());
stylesPart.Stylesheet.NumberingFormats = new NumberingFormats(
new NumberingFormat { NumberFormatId = IntFormatId, FormatCode = "#" },
new NumberingFormat { NumberFormatId = DecimalFormatId, FormatCode = "#.00" },
new NumberingFormat { NumberFormatId = DateFormatId, FormatCode = "dd.mm.yyyy" });
#endregion
#region общие строки
var sharedStringTablePart = workbookPart.AddNewPart<SharedStringTablePart>();
sharedStringTablePart.SharedStringTable = new SharedStringTable();
#endregion
#region Тело и листы документа
var currentSheetsCount = workbookPart!.WorksheetParts.Count();
var worksheetPart = workbookPart.AddNewPart<WorksheetPart>();
worksheetPart.Worksheet = new Worksheet();
worksheetPart.Worksheet.AppendChild(new Columns());
worksheetPart.Worksheet.AppendChild(new SheetData());
worksheetPart.Worksheet.AppendChild(new MergeCells());
var sheets = workbookPart.Workbook.GetFirstChild<Sheets>();
if (sheets == null)
{
sheets = workbookPart.Workbook.AppendChild(new Sheets());
}
sheets.Append(new Sheet
{
Name = $"Заказ №{order.PurchaseNumber}",
Id = workbookPart.GetIdOfPart(worksheetPart),
SheetId = (uint)currentSheetsCount + 1
});
#endregion
return doc;
}
Среди прочих элементов мы настроили базовые форматы данных в свойстве Stylesheet.NumberingFormats. Это форматы дат, а также целых и вещественных чисел. Ссылки на них будут использованы далее при выводе данных в ячейки таблицы.
В конце метода InitDocument мы настроили главный (и единственный) лист и заполнили его имя номером заказа из данных.
Вывод данных
Перед выводом данных в лист Excel зададим размеры столбцов. Единица измерения размера — так называемые символы excel, равные 7 пикселям при значении dpi=96.
private float CmToExcelCharacters(float cmSize)
{
return cmSize / 2.54f * 96 / 7;
}
private void PrintPurchase(SpreadsheetDocument document, PurchaseOrder order)
{
#region Настройка параметров листа
var rowCounter = 0u;
var worksheet = document.WorkbookPart!.WorksheetParts.First().Worksheet;
var sheet = worksheet.GetFirstChild<SheetData>()!;
var mergeCells = worksheet.GetFirstChild<MergeCells>();
var columns = worksheet.GetFirstChild<Columns>();
var stylesDict = new Dictionary<StyleParams, uint>();
columns.Append(
new Column { Min = 1, Max = 1, Width = CmToExcelCharacters(1), CustomWidth = true },
new Column { Min = 2, Max = 2, Width = CmToExcelCharacters(2), CustomWidth = true },
new Column { Min = 3, Max = 3, Width = CmToExcelCharacters(5), CustomWidth = true },
new Column { Min = 4, Max = 4, Width = CmToExcelCharacters(2.2f), CustomWidth = true },
new Column { Min = 5, Max = 5, Width = CmToExcelCharacters(2), CustomWidth = true },
new Column { Min = 6, Max = 6, Width = CmToExcelCharacters(2), CustomWidth = true });
#endregion
// ...
}
Далее заполним шапку таблицы. Она заполняется текстовыми значениями, которые мы разместим в разделе общих строк (SharedStrings) документа. В значениях ячеек будут даны ссылки на эти строки по их индексу в разделе, а также будет указан тип значения ячейки — «общий». Альтернативный способ задания текста в xlsx — встраивание в таблицу. Его мы применим ниже при выводе текстовых данных из заказа.
В листинге ниже, помимо вывода шапки, также проводится настройка стилей ячеек. Детали работы со стилями в xlsx будут описаны ниже в соответствующем разделе.
/// <summary>
/// Добавить общую строку в документ
/// </summary>
private int AppendSharedString(SpreadsheetDocument document, string sharedString)
{
OpenXmlElement siContent;
var textEl = new Text(sharedString) { Space = SpaceProcessingModeValues.Preserve };
siContent = textEl;
var sharedStringTablePart = document.WorkbookPart!.GetPartsOfType<SharedStringTablePart>().Single();
sharedStringTablePart.SharedStringTable.AppendChild(new SharedStringItem(siContent));
return sharedStringTablePart.SharedStringTable.ChildElements.Count - 1;
}
private void PrintPurchase(SpreadsheetDocument document, PurchaseOrder order)
{
// ...
#region Заполнение шапки документа
var titleStyle = basicStyle with { FontSize = 12, Bold = true };
AddTextRow($"Заказ №{order.PurchaseNumber} от {order.PurchaseDate.ToLongDateString()}", titleStyle);
AddTextRow($"Клиент: {order.CustomerName}", titleStyle);
AddTextRow($"Адрес доставки: {order.CustomerAddress}", titleStyle);
var tableHeaderStyle = basicStyle with { Bold = true, Fill = "DDDDDD" };
AddTextRow($"Состав заказа", tableHeaderStyle with { FontSize = 12, HorizontalAlignment = HorizontalAlignmentValues.Center });
var tableTitlesRow = GetNextRow();
var tableTitles = new[] { "№", "Код", "Наименование", "Количество", "Цена", "Стоимость" };
var cellIdx = 0;
foreach (var title in tableTitles)
{
var cell = CreateCell(tableTitlesRow, cellIdx++, tableHeaderStyle);
cell.CellValue = new CellValue(AppendSharedString(document, title));
cell.DataType = CellValues.SharedString;
}
#endregion
// ...
Row GetNextRow()
{
var r = new Row { RowIndex = ++rowCounter };
sheet.Append(r);
return r;
}
Cell CreateCell(Row row, int cellIndex, StyleParams styleParams, uint colSpan = 1)
{
var columnCode = Convert.ToChar('A' + cellIndex % 26);
var cellCode = $"{columnCode}{rowCounter}";
var cell = new Cell { CellReference = cellCode };
var styleId = FindOrAppendStyle(styleParams);
cell.StyleIndex = styleId;
row.Append(cell);
if (colSpan > 1)
{
var endCol = Convert.ToChar(columnCode + colSpan - 1);
mergeCells!.AppendChild(new MergeCell
{
Reference = $"{columnCode}{row.RowIndex}:{endCol}{row.RowIndex}"
});
for (var i = 1; i < colSpan; i++)
{
columnCode++;
cellCode = $"{columnCode}{rowCounter}";
row.Append(new Cell { CellReference = cellCode, StyleIndex = styleId });
}
}
return cell;
}
void AddTextRow(string text, StyleParams styleParams)
{
var textRow = GetNextRow();
var textCell = CreateCell(textRow, 0, styleParams, 6);
textCell.CellValue = new CellValue(text);
textCell.DataType = CellValues.String;
}
uint FindOrAppendStyle(StyleParams styleParams)
{
if (!stylesDict.TryGetValue(styleParams, out var styleId))
{
styleId = stylesDict[styleParams] = AppendTextStyle(document, styleParams);
}
return styleId;
}
}
Как видно из кода, документ заполняется последовательно строками (Rows), которые, в свою очередь, последовательно заполняются ячейками (Cells). Каждой строке устанавливается её номер (RowIndex), начиная с 1. Каждой ячейке устанавливается код (CellCode), равный номеру строки и буквенному коду столбца, например, A1. Здесь приведена упрощённая логика формирования буквенного кода, не учитывающая возможность присутствия в документе многобуквенных кодов столбцов, начинающихся в обычной таблице после столбца Z. Это сделано для того, чтобы не усложнять алгоритмы, в реальном же коде эта возможность должна быть учтена.
Далее циклически заполним данные заказа и итоговые ячейки. Процедура заполнения строк и столбцов таблицы будет аналогична вышеприведённым примерам, нюансы настройки отдельных областей данных, такие как заполнение формул, объединённых ячеек и т.д. поясним ниже.
private void PrintPurchase(SpreadsheetDocument document, PurchaseOrder order)
{
// ...
#region Заполнение данных заказа
var rn = 0;
foreach (var item in order.Items)
{
rn++;
cellIdx = 0;
var itemRow = GetNextRow();
var numCell = CreateCell(itemRow, cellIdx++, basicStyle with { HorizontalAlignment = HorizontalAlignmentValues.Center, NumFormatId = IntFormatId });
numCell.CellValue = new CellValue(rn);
numCell.DataType = CellValues.Number;
var codeCell = CreateCell(itemRow, cellIdx++, basicStyle);
codeCell.CellValue = new CellValue(item.ProductCode);
codeCell.DataType = CellValues.String;
var nameCell = CreateCell(itemRow, cellIdx++, basicStyle);
nameCell.CellValue = new CellValue(item.ProductName);
nameCell.DataType = CellValues.String;
var cntCell = CreateCell(itemRow, cellIdx++, basicStyle with { HorizontalAlignment = HorizontalAlignmentValues.Center, NumFormatId = IntFormatId });
cntCell.CellValue = new CellValue(item.Count);
cntCell.DataType = CellValues.Number;
var priceCell = CreateCell(itemRow, cellIdx++, basicStyle with { HorizontalAlignment = HorizontalAlignmentValues.Center, NumFormatId = DecimalFormatId });
priceCell.CellValue = new CellValue(item.UnitPrice);
priceCell.DataType = CellValues.Number;
var sumCell = CreateCell(itemRow, cellIdx, basicStyle with { HorizontalAlignment = HorizontalAlignmentValues.Center, NumFormatId = DecimalFormatId });
sumCell.CellFormula = new CellFormula($"{cntCell.CellReference}*{priceCell.CellReference}");
}
#endregion
#region Заполнение итогов
var totalsRow = GetNextRow();
var totalsStyle = basicStyle with { Bold = true, HorizontalAlignment = HorizontalAlignmentValues.Center };
var totalsTitle = CreateCell(totalsRow, 0, totalsStyle with { HorizontalAlignment = HorizontalAlignmentValues.Right }, 3);
totalsTitle.CellValue = new CellValue("ИТОГО:");
totalsTitle.DataType = CellValues.String;
var totalCountCell = CreateCell(totalsRow, 3, totalsStyle with { NumFormatId = IntFormatId });
totalCountCell.CellFormula = new CellFormula($"SUM(D{totalsRow.RowIndex! - order.Items.Length}:D{totalsRow.RowIndex! - 1})");
var emptyCell = CreateCell(totalsRow, 4, totalsStyle);
var totalSumCell = CreateCell(totalsRow, 5, totalsStyle with { NumFormatId = DecimalFormatId });
totalSumCell.CellFormula = new CellFormula($"SUM(F{totalsRow.RowIndex! - order.Items.Length}:F{totalsRow.RowIndex! - 1})");
#endregion
// ...
}
Объединение ячеек

Для объединения диапазона ячеек в таблице необходимо внести соответствующую запись в раздел листа MergeCells, который в листе предшествует основному содержимому SheetData. Запись MergeCell содержит свойство Reference, значение которого включает коды ячеек левого верхнего и правого нижнего углов диапазона объединяемых ячеек, например: A1:C3. Важно, чтобы массив MergeCells не содержал пересекающиеся диапазоны объединенных ячеек, т.к. это вызывает ошибки при открытии xlsx-файла в различных редакторах.
if (colSpan > 1)
{
var endCol = Convert.ToChar(columnCode + colSpan - 1);
mergeCells!.AppendChild(new MergeCell
{
Reference = $"{columnCode}{row.RowIndex}:{endCol}{row.RowIndex}"
});
for (var i = 1; i < colSpan; i++)
{
columnCode++;
cellCode = $"{columnCode}{rowCounter}";
row.Append(new Cell { CellReference = cellCode, StyleIndex = styleId });
}
}
Кроме того, необходимо заполнить в документе все ячейки, входящие в диапазон, и оставить их пустыми. Без них, например, не будет полностью отрисована граница объединённой ячейки.
Настройка формул
Для заполнения ячеек расчётными формулами вместо свойства Cell.CellValue используется свойство Cell.CellFormula. Оно заполняется строкой с выражением формулы, например:
var sumCell = CreateCell(itemRow, cellIdx, basicStyle with { HorizontalAlignment = HorizontalAlignmentValues.Center, NumFormatId = DecimalFormatId });
sumCell.CellFormula = new CellFormula($"{cntCell.CellReference}*{priceCell.CellReference}");
или
var totalCountCell = CreateCell(totalsRow, 3, totalsStyle with { NumFormatId = IntFormatId });
totalCountCell.CellFormula = new CellFormula($"SUM(D{totalsRow.RowIndex! - order.Items.Length}:D{totalsRow.RowIndex! - 1})");
Другие параметры, такие как тип данных ячейки, заполнять не требуется. Тип будет выставлен автоматически.
Настройка стилей
Стили документа хранятся в отдельном zip-архивном файле, который описывается частью документа StylesheetPart. Каждый стиль включает в себя параметры шрифта, заливки, формата ячейки и границы ячейки. В разделе стилей следует заводить новый, если вам потребовалось внести изменения в любой из этих параметров. Привязка стиля к ячейке указывается в свойстве StyleId, значение которого равно индексу стиля в таблице:
var styleId = FindOrAppendStyle(styleParams);
cell.StyleIndex = styleId;
Сами стили в нашем примере заполняются в методе AppendTextStyle. Для более оптимального заполнения используется тип записи StyleParams, по экземплярам которого строится словарь уже добавленных в документ стилей. В StyleParams перечислены только те параметры стиля, которые меняются в нашем примере, в других документах их может быть больше.
private record StyleParams
{
public string FontFamily { get; set; }
public int FontSize { get; set; }
public bool Bold { get; set; }
public string Fill { get; set; }
public HorizontalAlignmentValues HorizontalAlignment { get; set; }
public uint? NumFormatId { get; set; }
}
private uint AppendTextStyle(SpreadsheetDocument document, StyleParams styleParams)
{
var stylesheet = document.WorkbookPart.WorkbookStylesPart.Stylesheet;
stylesheet.Fonts.AppendChild(new Font
{
FontName = new FontName { Val = styleParams.FontFamily },
FontSize = new FontSize { Val = styleParams.FontSize },
Bold = new Bold { Val = styleParams.Bold }
});
var fontId = stylesheet.Fonts.Count++;
var fillId = 0u;
var border = new Border();
border.Append(new LeftBorder { Style = BorderStyleValues.Thin, Color = new Color { Rgb = "000000" } });
border.Append(new RightBorder { Style = BorderStyleValues.Thin, Color = new Color { Rgb = "000000" } });
border.Append(new TopBorder { Style = BorderStyleValues.Thin, Color = new Color { Rgb = "000000" } });
border.Append(new BottomBorder { Style = BorderStyleValues.Thin, Color = new Color { Rgb = "000000" } });
stylesheet.Borders.AppendChild(border);
var borderId = stylesheet.Borders.Count++;
if (!string.IsNullOrEmpty(styleParams.Fill))
{
stylesheet.Fills.AppendChild(new Fill
{
PatternFill = new PatternFill
{
PatternType = PatternValues.Solid, ForegroundColor = new ForegroundColor
{
Rgb = styleParams.Fill
}
}
});
fillId = stylesheet.Fills.Count++;
}
var alignment = new Alignment
{
Horizontal = styleParams.HorizontalAlignment,
Vertical = VerticalAlignmentValues.Center,
WrapText = true
};
stylesheet.CellFormats.AppendChild(new CellFormat
{
BorderId = borderId,
ApplyBorder = true,
FillId = fillId,
ApplyFill = true,
FontId = fontId,
ApplyFont = true,
NumberFormatId = styleParams.NumFormatId,
ApplyNumberFormat = true,
Alignment = alignment,
ApplyAlignment = true
});
return stylesheet.CellFormats.Count++;
}
В нашем примере мы везде используем полные границы ячеек, поэтому все стили содержат одинаковые настройки границ. В других случаях может изменяться состав границы (лево/право/верх/низ), а также цвет, толщина и рисунок границы.
Настройка форматов ячеек

Как было упомянуто выше, формат ячейки задаётся с помощью ссылки на один из преднастроенных в документе форматов.
stylesPart.Stylesheet.NumberingFormats = new NumberingFormats(
new NumberingFormat { NumberFormatId = IntFormatId, FormatCode = "#" },
new NumberingFormat { NumberFormatId = DecimalFormatId, FormatCode = "#.00" },
new NumberingFormat { NumberFormatId = DateFormatId, FormatCode = "dd.mm.yyyy" });
Сама ссылка сохраняется в стиле, применённом к ячейке.
stylesheet.CellFormats.AppendChild(new CellFormat
{
NumberFormatId = styleParams.NumFormatId,
});
Кроме того, нужно корректно задать тип данных ячейки, к которой применён стиль с заданным форматом, например:
numCell.DataType = CellValues.Number;
Стандарт OpenXml содержит свой язык масок форматов чисел и дат, позволяющих задавать, например, число знаков после запятой у чисел, символы-разделители дробной части и порядков, или форматы вывода дат. Однако этот язык содержит некоторые ограничения, например, на возможные используемые символы-разделители у чисел.
Заключение
В этой статье мы продемонстрировали логику печати документа в формате таблицы xlsx. Код демонстрационного проекта ещё может быть улучшен в части оптимального заполнения повторяющихся элементов документа, в примерах же приоритет был отдан простоте кода и наглядности того, что он делает. В примере затронуто большинство базовых функций xlsx, таких как объединение ячеек и заполнение расчётных формул. С API для настройки более специфических элементов таблиц xlsx можно познакомиться в онлайн-документации.
С полным исходным кодом всего проекта можно ознакомиться по ссылке. Данная статья завершает наш цикл обзора возможностей работы с документами «офисных» форматов в .NET.
Мы в «БАРС Груп» разрабатываем цифровые решения для государства, бизнеса и людей. Принимаем активное участие в реализации Национального проекта «Цифровая экономика» и создаем цифровые решения для импортозамещения программного обеспечения —88 решений компании зарегистрировано в реестре российского ПО. Рассказываем о наших продуктах и ИТ-трендах в Telegram-канале. Сервис печати Sprinter — наш новый продукт, уже входящий в реестр. Он помогает разработчикам и аналитикам с печатью документов по заданным шаблонам. А ещё благодаря ему увидела свет эта статья.
Комментарии (3)
NightBlade74
16.06.2025 13:53Какой кошмарный стиль разметки регионами. Зачем внутри тела метода несколько регионов? А зачем там один регион на все тело? Если метод длинный, то его следует разить на несколько методов. Если короткий - его и так можно в среде свернуть. Между регионами нет пустых строк.
Хотели сделать код более читаемым? Не получилось. Сделали хуже.
3263927
а closedxml не кажется более удобным?
Dhwtj
Лучше epplus