MigraDoc

MigraDoc позволяет собирать из C#-кода многостраничные PDF- и RTF-документы: отчёты, счета, акты, каталоги и формы с абзацами, стилями, таблицами, изображениями, колонтитулами, полями нумерации, закладками и диаграммами. Разработчик описывает структуру через объектную модель, а движок сам рассчитывает переносы строк, разрывы страниц и расположение элементов.

Работа строится не вокруг ручного рисования каждой страницы, а вокруг логических объектов документа. Сначала создают Document и Section, затем добавляют Paragraph, Table, Image, TextFrame или Chart, назначают стили и параметры страницы, после чего PdfDocumentRenderer выполняет компоновку. Такой подход особенно удобен там, где один шаблон должен заполняться данными сотни или тысячи раз.

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

Скачать MigraDoc

Оценка 9.7 Рекомендуем
  • Редактирование PDF
  • Русский интерфейс
  • Просто новичкам
Скачать бесплатно на Windows
Лучшая альтернатива
MigraDoc
Оценка 8.5
  • Нет визуального редактора
  • HTML не импортируется
  • Не редактирует готовый PDF
Скачать MigraDoc
Загрузка начнётся после нажатия

Как устроен рабочий процесс в MigraDoc

Типовой сценарий начинается с отделения данных от оформления. Данные счёта, заказа или отчёта остаются в обычных моделях приложения, а отдельный класс создаёт документ. Этот класс получает подготовленные значения, формирует разделы и возвращает объект Document. Благодаря такому разделению шаблон не зависит от базы данных, контроллера веб-приложения или способа доставки файла, а тест может передать ему фиксированный набор данных и проверить результат.

В объекте Document хранятся сведения о документе, коллекция Styles и последовательность Section. Секция определяет формат страницы, поля, ориентацию, колонтитулы и собственный поток содержимого. Внутри секции располагаются абзацы, таблицы, изображения, диаграммы и текстовые рамки. Страницы при этом ещё не существуют: они появляются только во время компоновки, когда рендерер измеряет текст и распределяет содержимое.

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

Титульная страница официального примера MigraDoc

Финальный этап состоит из двух операций. RenderDocument вычисляет итоговую раскладку и создаёт объект PDFsharp PdfDocument, а Save записывает его в поток или файл. Между этими операциями можно изменить параметры PDF, добавить низкоуровневую графику через PDFsharp, установить свойства просмотра или проверить количество созданных страниц. Для RTF применяется отдельный рендерер, поскольку RTF остаётся редактируемым потоком и окончательные разрывы зависят от программы, которая его открывает.

Подключение пакета и минимальный документ

Для проекта, которому не нужны элементы Windows Forms или WPF, обычно подключают пакет PDFsharp-MigraDoc. Он содержит объектную модель и PDF-рендеринг, рассчитанные на современный .NET и совместимый профиль .NET Standard. В Windows-проектах с предпросмотром или зависимостью от конкретной графической подсистемы применяют пакеты с суффиксами GDI или WPF. Выбор должен совпадать с целевой платформой всего приложения: смешивание графических вариантов в одной сборке приводит к конфликтам типов и различиям в измерении шрифтов.

dotnet add package PDFsharp-MigraDoc

using MigraDoc.DocumentObjectModel;
using MigraDoc.Rendering;

var document = new Document();
var section = document.AddSection();
section.AddParagraph("Первый документ MigraDoc");

var renderer = new PdfDocumentRenderer
{
    Document = document
};
renderer.RenderDocument();
renderer.PdfDocument.Save("result.pdf");

В минимальном примере важны четыре шага: создать документ, добавить хотя бы одну секцию, поместить в неё содержимое и передать объект рендереру. Если секцию не создать, добавлять абзацы будет некуда. Если присвоить Document после вызова RenderDocument, рендерер не увидит содержимое. Если сохранить PdfDocument до компоновки, файл окажется пустым или операция завершится ошибкой.

В серверном коде удобнее сохранять не по фиксированному пути, а в MemoryStream. Поток можно вернуть из контроллера, поместить в объектное хранилище, прикрепить к письму или записать в базу. Важно не закрывать поток раньше, чем потребитель прочитает данные, и устанавливать Position в ноль перед повторным чтением.

public static byte[] BuildPdf(Document document)
{
    var renderer = new PdfDocumentRenderer { Document = document };
    renderer.RenderDocument();

    using var stream = new MemoryStream();
    renderer.PdfDocument.Save(stream, closeStream: false);
    return stream.ToArray();
}

Для повторяемой генерации лучше не хранить Document в статическом поле. Объектная модель изменяемая: во время построения в неё добавляются элементы, а при рендеринге выполняется разворачивание наследуемых форматов. Каждый запрос должен получать собственный экземпляр документа и собственный рендерер. Общими могут быть неизменяемые данные, кэш байтов шрифтов и функции, которые создают стили.

Документная модель и стили

Стили — основной инструмент для поддерживаемого шаблона. В коллекции Styles уже есть Normal, заголовочные стили, стили колонтитулов и другие предопределённые записи. Почти все пользовательские стили разумно наследовать от Normal или подходящего заголовка. Тогда смена базового шрифта, межстрочного интервала или цвета выполняется в одном месте, а не в сотнях присваиваний.

static void DefineStyles(Document document)
{
    var normal = document.Styles[StyleNames.Normal]!;
    normal.Font.Name = "Noto Sans";
    normal.Font.Size = 10;
    normal.ParagraphFormat.SpaceAfter = Unit.FromPoint(5);
    normal.ParagraphFormat.WidowControl = true;

    var h1 = document.Styles.AddStyle("ReportHeading", StyleNames.Heading1);
    h1.Font.Name = "Noto Sans";
    h1.Font.Size = 18;
    h1.Font.Bold = true;
    h1.ParagraphFormat.SpaceBefore = Unit.FromPoint(12);
    h1.ParagraphFormat.SpaceAfter = Unit.FromPoint(8);
    h1.ParagraphFormat.KeepWithNext = true;
}

Наследование работает каскадно. Формат может прийти из Normal, из родительского стиля, из стиля конкретного объекта и, наконец, из прямого форматирования. Прямое значение имеет наибольший приоритет. Поэтому чрезмерное использование paragraph.Format.Font затрудняет последующую смену оформления: стиль меняется, а явно заданные параметры остаются прежними. Для повторяемых элементов лучше создать именованный стиль, а прямое форматирование оставить для единичных выделений.

Метод ClearAll у ParagraphFormat полезен, когда надо полностью отказаться от унаследованных настроек, но применять его следует осознанно. После очистки исчезнут не только отступы, но и другие значения, на которые рассчитывал шаблон. Более безопасный приём — переопределить конкретные свойства или создать стиль с понятным родителем.

Единицы задаются через Unit. Можно использовать точки, сантиметры, миллиметры и дюймы, а строковые значения наподобие 1.2cm преобразуются в Unit автоматически в поддерживаемых местах. В сложном шаблоне лучше выбрать одну систему измерения и придерживаться её. Смешение сантиметров в полях, точек в таблицах и строковых значений в отступах усложняет проверку доступной ширины.

Пример выравнивания и форматирования абзацев MigraDoc

Текст, абзацы и локальное форматирование

Текст добавляется в Paragraph. Простой вызов AddParagraph со строкой создаёт абзац и сразу помещает в него текст. Когда внутри одной строки нужны разные начертания, применяют AddFormattedText, AddText и поля. FormattedText может менять шрифт, размер, цвет, полужирное и курсивное начертание, надстрочный или подстрочный режим. Это подходит для номера договора, который должен выделяться внутри обычного предложения, или для индекса единицы измерения.

var paragraph = section.AddParagraph();
paragraph.AddText("Заказ ");
var number = paragraph.AddFormattedText("№ 4821");
number.Bold = true;
number.Color = Colors.DarkBlue;
paragraph.AddText(" сформирован ");
paragraph.AddDateField("dd.MM.yyyy");
paragraph.AddText(".");

ParagraphFormat управляет выравниванием, левым и правым отступом, красной строкой, интервалами, табуляцией, границами и заливкой. Для обычного текста доступны Left, Center, Right и Justify. Выравнивание по ширине выглядит аккуратно только при достаточной длине строки и подходящем шрифте; в узких колонках оно способно создавать большие пробелы, поэтому для таблиц чаще выбирают выравнивание влево.

Табуляция полезна для строк, где слева находится подпись, по центру название раздела, а справа номер страницы. Позиции задаются в ParagraphFormat.TabStops, причём можно выбрать левое, правое, центральное или десятичное выравнивание. Для денежных значений десятичная табуляция даёт более ровный столбец, чем набор пробелов, но в больших наборах данных таблица остаётся надёжнее.

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

Границы и заливка абзацев в документе MigraDoc

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

Секции, формат страницы и разрывы

Section — граница, на которой меняются параметры страницы. Новая секция начинается с новой страницы и может иметь другой формат, ориентацию, поля и колонтитулы. Это удобно для отчёта, где основная часть идёт на портретных страницах A4, а широкая таблица размещается на альбомной странице. После широкой таблицы создают ещё одну секцию и возвращают портретную ориентацию.

var portrait = document.AddSection();
portrait.PageSetup.PageFormat = PageFormat.A4;
portrait.PageSetup.Orientation = Orientation.Portrait;

var landscape = document.AddSection();
landscape.PageSetup.PageFormat = PageFormat.A4;
landscape.PageSetup.Orientation = Orientation.Landscape;
landscape.PageSetup.LeftMargin = Unit.FromCentimeter(1.5);
landscape.PageSetup.RightMargin = Unit.FromCentimeter(1.5);

Параметры PageSetup наследуются от предыдущей секции. Это удобно для продолжения документа, но способно вызвать неожиданный результат: настройка чётных колонтитулов, альбомная ориентация или особый тип начала секции могут перейти дальше. Когда следующая часть должна вернуться к стандартным параметрам, ей присваивают клон DefaultPageSetup или явно задают все значимые поля.

Для ручного перехода применяют AddPageBreak. Однако постоянное добавление разрывов для подгонки шаблона плохо переносит изменение данных. Предпочтительнее использовать KeepWithNext для заголовка, KeepTogether для короткого абзаца и WidowControl для предотвращения одиночной первой или последней строки. KeepTogether нельзя бездумно ставить на большой блок: если он физически не помещается на страницу, рендереру всё равно придётся нарушить ожидание или сформировать неудачную раскладку.

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

Колонтитулы, поля и нумерация

Колонтитулы привязаны к секции. В Primary задаётся обычный вариант, в FirstPage — оформление первой страницы секции, в EvenPage — вариант для чётных страниц. Чтобы специальные варианты использовались, включают DifferentFirstPageHeaderFooter и OddAndEvenPagesHeaderFooter в PageSetup. Если флаг выключен, заполненный объект FirstPage сам по себе не появится в PDF.

section.PageSetup.DifferentFirstPageHeaderFooter = true;
section.PageSetup.OddAndEvenPagesHeaderFooter = true;

section.Headers.FirstPage.AddParagraph("Конфиденциальный отчёт");
section.Headers.Primary.AddParagraph("Отчёт по операциям");
section.Headers.EvenPage.AddParagraph("Отчёт по операциям");

var footer = section.Footers.Primary.AddParagraph();
footer.Format.Alignment = ParagraphAlignment.Right;
footer.AddText("Страница ");
footer.AddPageField();
footer.AddText(" из ");
footer.AddNumPagesField();

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

Поля вставляют динамические значения: текущую дату, номер страницы, количество страниц, номер секции, сведения из Document.Info и текст, связанный с закладкой. Формат DateField использует правила форматирования .NET. Чтобы дата не зависела от культуры сервера, лучше передавать заранее сформированную строку, если документ должен быть юридически стабильным. DateField берёт момент рендеринга, а не дату бизнес-операции.

Колонтитулы наследуются в следующую секцию как копии. Если в новой секции они не нужны, требуется присвоить пустые HeaderFooter для Primary, FirstPage и EvenPage и сбросить соответствующие флаги PageSetup. Простое отсутствие кода не означает очистку: предыдущий колонтитул продолжит отображаться.

Таблицы для отчётов, счетов и реестров

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

var table = section.AddTable();
table.Borders.Width = Unit.FromPoint(0.5);
table.Rows.LeftIndent = 0;

var nameColumn = table.AddColumn(Unit.FromCentimeter(9.2));
var quantityColumn = table.AddColumn(Unit.FromCentimeter(2.2));
var priceColumn = table.AddColumn(Unit.FromCentimeter(2.7));
var totalColumn = table.AddColumn(Unit.FromCentimeter(3.0));

quantityColumn.Format.Alignment = ParagraphAlignment.Right;
priceColumn.Format.Alignment = ParagraphAlignment.Right;
totalColumn.Format.Alignment = ParagraphAlignment.Right;

Для шапки добавляют строку, присваивают HeadingFormat = true и настраивают заливку, полужирный шрифт и выравнивание. Строка с HeadingFormat повторяется при переносе таблицы на следующую страницу. Если шапка состоит из нескольких строк, свойство задают каждой из них подряд в начале таблицы. Промежуточная обычная строка разрывает последовательность повторяемой шапки.

var header = table.AddRow();
header.HeadingFormat = true;
header.Format.Font.Bold = true;
header.Shading.Color = Colors.LightGray;
header.Cells[0].AddParagraph("Наименование");
header.Cells[1].AddParagraph("Кол-во");
header.Cells[2].AddParagraph("Цена");
header.Cells[3].AddParagraph("Сумма");

Ячейки содержат коллекцию Elements, поэтому в них можно размещать несколько абзацев и другие поддерживаемые объекты. Для объединения по горизонтали применяют MergeRight, по вертикали — MergeDown. Значение указывает число соседних ячеек, которые надо присоединить. После объединения содержимое следует добавлять в левую верхнюю ячейку объединённой области; заполнение скрытых ячеек создаёт путаницу и может дать неожиданный результат.

Таблицы, выравнивание и объединение ячеек MigraDoc

Внутренние отступы задаются через LeftPadding, RightPadding, TopPadding и BottomPadding на таблице, строке или ячейке. Наследование позволяет задать общие значения на таблице и переопределить их в шапке. Слишком маленький вертикальный отступ делает реестр плохо читаемым, а слишком большой резко увеличивает число страниц. Для плотной финансовой таблицы обычно важнее стабильность высоты и читаемость десятичных значений, чем декоративное пространство.

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

Удержание строк таблицы управляется свойством KeepWith. Оно задаётся на строке и сообщает, сколько следующих строк желательно сохранить вместе. Приём полезен для блока заголовок группы + первая строка данных или для итоговой строки с пояснением. Значение не должно охватывать длинный фрагмент, превышающий страницу, иначе раскладка станет непредсказуемой.

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

Изображения, текстовые рамки и позиционирование

Изображение добавляется по пути к файлу, из поддерживаемого ресурса или через имя, которое разрешает пользовательская логика работы с изображениями. Ширину и высоту можно задавать независимо, но для сохранения пропорций обычно меняют один размер и оставляют LockAspectRatio. Если исходный файл имеет большую пиксельную размерность, уменьшение отображаемой ширины не всегда автоматически уменьшает размер PDF: для массовых документов изображения лучше заранее оптимизировать.

var logo = section.AddImage("assets/logo.png");
logo.Width = Unit.FromCentimeter(3.2);
logo.LockAspectRatio = true;
logo.Left = ShapePosition.Left;
logo.WrapFormat.Style = WrapStyle.TopBottom;

Путь к изображению может быть абсолютным или относительным. Для относительного пути рендерер использует WorkingDirectory. В веб-приложении текущий каталог процесса часто отличается от каталога опубликованных файлов, поэтому надёжнее явно задать рабочую папку или построить абсолютный путь через окружение хоста. Ошибка пути проявляется уже при рендеринге, а не при создании Image в объектной модели.

TextFrame размещает абзацы, таблицы и другое содержимое вне обычного потока. Ей задают ширину, высоту и положение относительно страницы, полей или текущего абзаца. Рамка подходит для адресного окна конверта, боковой пометки, штампа или фиксированного блока реквизитов. Она не расширяется бесконечно без последствий: динамический текст может выйти за отведённую высоту, поэтому содержимое рамок должно иметь контролируемый объём.

Для большинства бизнес-документов устойчивее потоковая компоновка, а абсолютное позиционирование стоит оставлять для элементов с действительно фиксированным местом. Если попытаться собрать всю страницу из десятков рамок, изменение шрифта или длины адреса приведёт к перекрытиям. Таблица без границ часто лучше решает задачу выравнивания реквизитов и при этом корректно увеличивается по высоте.

Гиперссылки, закладки и оглавление

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

var heading = section.AddParagraph("Финансовые показатели", "ReportHeading");
heading.AddBookmark("financial-results");

var tocLine = tocSection.AddParagraph();
var link = tocLine.AddHyperlink("financial-results");
link.AddText("Финансовые показатели");
tocLine.AddTab();
tocLine.AddPageRefField("financial-results");

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

Автоматического высокоуровневого генератора оглавления по всем стилям заголовков нет в том же смысле, что в текстовом редакторе. Практический шаблон добавляет закладку при создании каждого раздела и одновременно регистрирует элемент в коллекции оглавления. Затем в начале документа создаются строки со ссылками и PageRefField. Номера страниц определяются при рендеринге.

Оглавление с переходами в официальном документе MigraDoc

Диаграммы и графическое представление данных

В объектной модели есть Chart с коллекциями рядов, категорий, осей, легенд и областей текста. Она подходит для столбчатых, линейных и некоторых других деловых диаграмм, которые должны формироваться из числовых данных вместе с отчётом. Для каждой серии задают тип, значения и подписи, а категории добавляют через XSeries.

var chart = section.AddChart(ChartType.Column2D);
chart.Width = Unit.FromCentimeter(16);
chart.Height = Unit.FromCentimeter(9);

var revenue = chart.SeriesCollection.AddSeries();
revenue.Name = "Выручка";
revenue.Add(12.4, 15.1, 14.8, 18.6);
revenue.HasDataLabel = true;

var categories = chart.XValues.AddXSeries();
categories.Add("I кв.", "II кв.", "III кв.", "IV кв.");
chart.XAxis.Title.Caption = "Период";
chart.YAxis.Title.Caption = "млн руб.";

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

Столбчатая и линейная диаграммы в MigraDoc

Встроенные диаграммы рассчитаны на отчётную графику, а не на интерактивную визуализацию. В PDF нет подсказок при наведении и динамического фильтра. Если корпоративный стиль требует сложных комбинированных графиков, логарифмических шкал или особой подписи точек, практичнее создать SVG или PNG специализированным инструментом и добавить его как изображение.

MDDDL: текстовое описание документа

MigraDoc Document Description Language представляет объектную модель в текстовом виде. Документ можно записать через DdlWriter и при необходимости прочитать обратно через DdlReader. Формат удобен для диагностики: в файле видны секции, абзацы, стили, таблицы и значения свойств, поэтому его можно приложить к минимальному примеру ошибки без бизнес-логики приложения.

using MigraDoc.DocumentObjectModel.IO;

DdlWriter.WriteToFile(document, "report.mdddl");
var restored = DdlReader.DocumentFromFile("report.mdddl");

MDDDL не следует считать готовым визуальным шаблонизатором. Правка большого файла вручную требует знания грамматики, а ошибки синтаксиса обнаруживаются при чтении. Для управляемого проекта обычно удобнее формировать документ C#-методами, а DDL использовать как диагностический снимок, формат обмена внутри доверенной системы или основу для специализированного редактора.

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

Рендеринг в PDF и RTF

PDF-рендерер выполняет flattening: вычисляет итоговые значения свойств с учётом стилей и наследования, измеряет текст, размещает объекты, создаёт страницы и передаёт результат в PDFsharp. После RenderDocument доступен PdfDocument, где можно изменить PageLayout, ViewerPreferences, параметры сжатия и другие свойства PDFsharp до сохранения.

var renderer = new PdfDocumentRenderer { Document = document };
renderer.PdfDocument.PageLayout = PdfPageLayout.SinglePage;
renderer.PdfDocument.ViewerPreferences.FitWindow = true;
renderer.RenderDocument();
renderer.Save(outputPath);

Настройки, влияющие на раскладку, нужно задавать до RenderDocument. Изменение поля секции, шрифта или текста после компоновки не перестраивает уже созданные страницы автоматически. Если документ изменён, следует создать новый рендерер либо повторить полный цикл компоновки в соответствии с API, а не пытаться исправить отдельную страницу вручную.

RTF-рендерер не фиксирует окончательные координаты и количество страниц. Файл открывается в текстовом процессоре, который использует собственные шрифты, драйвер печати и алгоритмы разбиения. Поэтому PDF и RTF одного Document могут отличаться по переносам и числу страниц. Если нужен неизменный вид, эталоном должен быть PDF; RTF следует рассматривать как редактируемый вариант.

var rtfRenderer = new RtfDocumentRenderer();
rtfRenderer.Render(document, "report.rtf", workingDirectory);

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

Смешивание MigraDoc и PDFsharp

Высокоуровневая модель хорошо решает потоковый текст и таблицы, а PDFsharp даёт низкоуровневое рисование на конкретной странице. Их можно сочетать после компоновки. Например, MigraDoc создаёт основную структуру отчёта, затем код проходит по страницам PdfDocument и рисует водяной знак, штамп, метку контроля или графический элемент, которого нет в объектной модели.

renderer.RenderDocument();

foreach (var page in renderer.PdfDocument.Pages)
{
    using var gfx = XGraphics.FromPdfPage(page, XGraphicsPdfPageOptions.Append);
    var font = new XFont("Arial", 36);
    gfx.DrawString(
        "КОПИЯ",
        font,
        XBrushes.LightGray,
        new XRect(0, 0, page.Width.Point, page.Height.Point),
        XStringFormats.Center);
}

renderer.PdfDocument.Save("watermarked.pdf");

Такой код должен учитывать порядок слоёв. Рисование в режиме Append помещает графику поверх существующего содержимого, поэтому непрозрачная заливка способна закрыть текст. Для водяного знака выбирают прозрачную кисть или подходящий режим добавления. Кроме того, шрифт PDFsharp для надписи должен разрешаться так же надёжно, как шрифты основного документа.

Обратный сценарий — создать PdfDocument вручную и использовать рендерер для размещения MigraDoc-содержимого на подготовленных страницах — нужен реже и требует понимания низкоуровневого API. Он оправдан, когда каждая страница имеет нестандартный фон, бланк или импортированный шаблон. Для обычного отчёта проще дать MigraDoc управлять страницами целиком.

Настройка шрифтов и кириллицы

Все шрифты, упомянутые в стилях и объектах, должны разрешаться в момент рендеринга. На рабочем компьютере шрифт может находиться через системную установку, а в Linux-контейнере или облачной функции его не окажется. Надёжный производственный подход — включить разрешённые файлы шрифтов в развёртывание и зарегистрировать собственный IFontResolver, который возвращает нужные байты по имени семейства и начертанию.

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

public sealed class AppFontResolver : IFontResolver
{
    public byte[] GetFont(string faceName) => faceName switch
    {
        "NotoSans-Regular" => File.ReadAllBytes("fonts/NotoSans-Regular.ttf"),
        "NotoSans-Bold" => File.ReadAllBytes("fonts/NotoSans-Bold.ttf"),
        _ => throw new InvalidOperationException($"Unknown font: {faceName}")
    };

    public FontResolverInfo ResolveTypeface(string familyName, bool bold, bool italic)
    {
        if (!familyName.Equals("Noto Sans", StringComparison.OrdinalIgnoreCase))
            return PlatformFontResolver.ResolveTypeface(familyName, bold, italic);

        return new FontResolverInfo(bold ? "NotoSans-Bold" : "NotoSans-Regular");
    }
}

Интерфейс IFontResolver и доступность PlatformFontResolver зависят от выбранной графической сборки, поэтому пример адаптируют к целевой конфигурации. Главное правило — не полагаться на случайный набор шрифтов хоста. Один и тот же файл шрифта на всех окружениях делает измерение текста и количество страниц воспроизводимыми.

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

Практический сценарий: счёт с переменным числом позиций

Шаблон счёта удобно разделить на методы: DefineStyles, AddSellerBlock, AddCustomerBlock, AddItemsTable, AddTotals и AddSignatures. Основной метод только создаёт секцию и вызывает эти части. Такое разбиение упрощает тестирование и позволяет заменить шапку, не затрагивая расчёт итогов.

Денежные значения следует форматировать до добавления в документ с явной культурой и числом знаков после запятой. MigraDoc не является бухгалтерским движком и не должен вычислять налог или округление. Он получает уже проверенные суммы. В ячейку добавляется строковое представление, а исходное decimal остаётся в модели для контрольного сравнения.

static string Money(decimal value) =>
    value.ToString("N2", CultureInfo.GetCultureInfo("ru-RU"));

foreach (var item in invoice.Items)
{
    var row = table.AddRow();
    row.Cells[0].AddParagraph(item.Name);
    row.Cells[1].AddParagraph(item.Quantity.ToString("0.###"));
    row.Cells[2].AddParagraph(Money(item.UnitPrice));
    row.Cells[3].AddParagraph(Money(item.Total));
}

После строк товаров добавляют итоговый блок. Его можно оформить отдельной таблицей справа или последними строками основной таблицы с объединением первых колонок. Важно не использовать пробелы для выравнивания подписей Итого и НДС: в пропорциональном шрифте они не обеспечивают стабильную позицию.

Для многостраничного счёта шапку таблицы делают повторяемой, а блок подписи удерживают вместе с итоговой суммой. Если подписи не помещаются, они переходят на следующую страницу целиком. При этом слишком большой KeepWith для десятков строк приведёт к обратному эффекту, поэтому связывают только итог, пояснение и две-три строки подписи.

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

Практический сценарий: аналитический отчёт

Аналитический отчёт обычно состоит из титульной страницы, оглавления, краткого резюме, таблиц и диаграмм. Для каждого раздела создают закладку, а данные таблиц группируют заранее. MigraDoc отвечает за представление, но не должен выполнять тяжёлые SQL-запросы или статистические расчёты во время построения абзацев.

Титульная страница формируется первой секцией или первым содержимым секции с ручным разрывом после неё. На ней размещают название, период, автора и дату формирования. Если титульная страница не должна иметь колонтитул, включают отдельный FirstPage и оставляют его пустым, а обычный колонтитул задают для Primary.

Широкие сводные таблицы переносят в альбомную секцию. Перед таблицей желательно добавить короткий абзац с пояснением показателей, а после неё вернуть портретную секцию. При экспорте в RTF следует помнить, что текстовый процессор может иначе интерпретировать переход ориентации и изменить разбиение.

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

Генерация в веб-приложении и фоновой задаче

В контроллере веб-приложения документ лучше строить в отдельном сервисе. Контроллер проверяет права, получает подготовленную модель, вызывает генератор и возвращает байты с типом application/pdf. Имя файла кодируют безопасно, не используя пользовательский ввод как путь. Временный файл не нужен, если все ресурсы доступны из памяти или из известных каталогов.

[HttpGet("orders/{id:int}/pdf")]
public async Task<IActionResult> GetOrderPdf(int id, CancellationToken ct)
{
    var model = await orderService.GetPrintableAsync(id, ct);
    if (model is null)
        return NotFound();

    var bytes = orderPdfBuilder.Build(model);
    return File(bytes, "application/pdf", $"order-{id}.pdf");
}

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

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

Логирование должно фиксировать идентификатор шаблона, число записей, длительность компоновки, число страниц и размер результата, но не содержимое конфиденциальных полей. При исключении полезно сохранить диагностический MDDDL в защищённое хранилище с ограниченным сроком, если политика данных это допускает.

Производительность и размер файла

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

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

Шрифты могут встраиваться в PDF и влиять на размер. Один семейный файл с большим набором символов иногда тяжелее нескольких изображений. Однако отказ от встраивания ради экономии способен нарушить отображение на другом устройстве. Решение принимают после проверки лицензионных условий шрифта и реального размера итогового файла.

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

Объект Document и рендерер удерживают содержимое в памяти. Для очень больших выгрузок следует оценить пиковое потребление, а не только размер итогового файла. Если отчёт состоит из независимых частей, иногда разумнее сформировать несколько PDF и объединить их средствами PDFsharp, чем создавать гигантскую объектную модель одним проходом.

Типичные ошибки и их устранение

Шрифт не найден

Исключение о невозможности разрешить гарнитуру означает, что имя в стиле не сопоставлено с доступным файлом. Проверяют регистр и написание семейства, регистрацию IFontResolver, включение TTF или OTF в публикацию и разрешение на чтение. На Linux нельзя рассчитывать, что привычные Windows-шрифты установлены по умолчанию.

Если ошибка возникает только для полужирного текста, резолвер возвращает обычное начертание, но не знает bold-face. Нужно добавить отдельный файл и корректно обработать флаги bold и italic. Искусственная имитация начертания может отличаться по метрикам и внешнему виду.

Изображение не найдено

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

Таблица выходит за поля

Суммируют ширины колонок и сравнивают с шириной страницы за вычетом полей. Учитывают левый отступ таблицы. Затем проверяют отрицательные отступы и рамки. Если таблица принципиально шире, меняют ориентацию секции, уменьшают поля, сокращают колонки или делят данные; уменьшение шрифта до нечитаемого размера не является хорошим решением.

Заголовок остаётся внизу страницы

Для стиля заголовка включают KeepWithNext. Если после заголовка идёт пустой абзац, свойство удержит именно его, а содержательный абзац всё равно может перейти. Следует удалить технические пустые абзацы и задавать интервалы через SpaceBefore и SpaceAfter.

Шапка таблицы не повторяется

HeadingFormat должен быть включён у первых строк таблицы без разрыва последовательности. Если свойство установлено у строки в середине, она не становится повторяемой шапкой. Также проверяют, что таблица действительно одна, а не создаётся заново для каждой группы.

Колонтитул неожиданно появился в следующем разделе

Это следствие наследования секций. В новой секции присваивают пустые HeaderFooter и сбрасывают флаги чётной и первой страницы. Аналогично проверяют SectionStart, ориентацию и поля, которые тоже наследуются.

PDF и RTF имеют разное число страниц

Разница ожидаема: PDF компонуется MigraDoc окончательно, а RTF перекомпоновывает программа просмотра. Для точного совпадения используют PDF. Если RTF обязателен, тестируют его в целевом текстовом процессоре с теми же шрифтами и настройками печати.

Содержимое рамки перекрывает текст

Проверяют фиксированную высоту TextFrame, положение относительно страницы и стиль обтекания. Динамический блок лучше вернуть в обычный поток или таблицу. Если рамка нужна, ограничивают длину входных данных и добавляют тест на максимальный сценарий.

После изменения стиля ничего не изменилось

Объекты могли получить прямое форматирование, перекрывающее стиль, либо документ был изменён после RenderDocument. Удаляют лишние присваивания Format и выполняют компоновку заново. Для диагностики записывают MDDDL и проверяют, какие свойства заданы непосредственно.

Ограничения, которые важно учесть заранее

MigraDoc не открывает готовый PDF как редактируемую документную модель. Его задача — создать новый документ из объектов. Для добавления страниц, слияния, шифрования или низкоуровневых изменений используют PDFsharp, а для полноценного редактирования содержимого существующего PDF требуется другой класс инструментов.

Встроенного преобразования произвольного HTML и CSS в эквивалентную раскладку нет. HTML необходимо разбирать самостоятельно, использовать стороннее расширение с ограниченным набором тегов либо выбрать движок HTML-to-PDF. Простая замена тегов на абзацы подходит только для контролируемого подмножества и требует обработки списков, таблиц, ссылок и встроенных изображений.

Визуальный конструктор макетов не является основным способом работы. Дизайн выражается через C# или MDDDL, поэтому изменения выполняет разработчик. Для команды, где шаблоны должны править редакторы без доступа к коду, понадобится собственный слой шаблонизации или другой продукт с дизайнером.

Печать на принтер не является задачей рендерера PDF. Он создаёт файл; дальнейшая печать зависит от приложения просмотра или печатного компонента. На сервере автоматическая печать связана с драйверами, очередями и правами ОС и должна проектироваться отдельно.

Поддержка сносок в PDF ограничена: существующий объект Footnote не даёт встроенного рендеринга с автоматическим размещением внизу страницы. Сноски имитируют абзацами, закладками и гиперссылками либо меняют структуру документа. Для научной верстки с развитым аппаратом сносок это существенное ограничение.

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

Автоматическое масштабирование таблиц по содержимому и перенос колонок на следующую горизонтальную страницу не выполняются. Ширины проектируют заранее. Отчёт, который получает непредсказуемые сотни колонок, лучше преобразовать в несколько таблиц, CSV-приложение или отдельные страницы по группам показателей.

Тестирование шаблонов

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

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

Набор данных должен покрывать минимальные, средние и предельные значения. Для адреса проверяют одну короткую строку и несколько длинных; для таблицы — ноль, одну строку, границу страницы и несколько страниц; для суммы — отрицательное значение, ноль и крупное число; для текста — кириллицу, латиницу и специальные символы.

Отдельный тест нужен для часового пояса и культуры. Если DateField или форматирование чисел зависят от окружения, результат локального компьютера может отличаться от сервера. Явная CultureInfo и передача бизнес-даты в модель делают документ предсказуемым.

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

Безопасность данных и устойчивость генерации

Все строки из базы следует считать данными, а не инструкциями шаблона. При использовании C# API текст добавляется как текст, но пути к изображениям, имена файлов и внешние ссылки требуют отдельной валидации. Пользователь не должен выбирать произвольный локальный путь или сетевой ресурс.

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

PDF может содержать метаданные, ссылки и встроенные шрифты. Перед выдачей внешнему получателю проверяют, что Document.Info не включает внутренние идентификаторы, путь к серверу или имя разработчика. Водяные знаки и отметки конфиденциальности добавляют как часть шаблона, но они не заменяют контроль доступа.

Временные диагностические файлы MDDDL и PDF могут содержать персональные данные. Их сохраняют только в защищённой папке, ограничивают срок хранения и удаляют после анализа. В обычном успешном запросе лучше не создавать такие файлы вообще.

Сравнение MigraDoc с аналогами

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

ПрограммаЛучше подходит дляГлавное ограничение
MigraDocОтчёты, счета и формы из объектной модели .NET с выводом в PDF или RTFНет визуального дизайнера и импорта произвольного HTML
QuestPDFПоточная PDF-вёрстка на современном C# с fluent API и быстрым предпросмотромОриентирован на создание PDF, а не RTF и не редактирование готовых файлов
iTextНизкоуровневые PDF-задачи, стандарты, подписи, формы и сложная обработкаЛицензия AGPL требует соблюдения условий или коммерческой лицензии
Aspose.PDFСоздание, конвертация и изменение существующих PDF в корпоративных системахПроприетарная коммерческая лицензия
Syncfusion PDFГенерация и обработка PDF в проектах, уже использующих экосистему SyncfusionУсловия бесплатного использования зависят от права на Community License

MigraDoc разумно выбирать, когда документ похож на отчёт текстового процессора: в нём есть стили, абзацы, таблицы, разделы и колонтитулы, а данные приходят из .NET-кода. QuestPDF удобнее командам, предпочитающим fluent-композицию и исключительно PDF. iText подходит для глубоких функций самого формата PDF и регулируемых сценариев, если лицензирование принято заранее. Aspose.PDF и Syncfusion полезны, когда требуется широкий коммерческий набор преобразований и поддержка производителя. Ручное исправление уже итогового файла относится к другой задаче и требует PDF-редактора, а не генератора документов.

Практические рекомендации по архитектуре шаблона

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

Создавайте небольшие методы, которые получают Section, Table или Cell и добавляют один логический блок. Метод AddAddress не должен знать о рендерере, а AddItemsTable — сохранять файл. Тогда блоки можно переставлять и тестировать отдельно.

Все повторяемые размеры и цвета выносите в константы или объект темы. Магические значения Unit.FromCentimeter, разбросанные по десяткам методов, затрудняют изменение полей. Тема может содержать ширины колонок, размеры шрифтов, цвета границ и интервалы.

Не используйте пустые абзацы для вертикального расстояния. SpaceBefore и SpaceAfter дают предсказуемую компоновку и не создают пустую строку, которую KeepWithNext может удержать вместо нужного текста. Исключение — осознанная пустая строка формы, предназначенная для заполнения от руки.

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

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

Ответы на частые практические вопросы

Можно ли создать PDF без сохранения на диск?

Да. PdfDocument сохраняют в MemoryStream и получают массив байтов. Такой способ подходит для HTTP-ответа, вложения и объектного хранилища. Изображения и шрифты всё равно должны быть доступны рендереру из памяти или разрешённых каталогов.

Можно ли заполнить готовый PDF-шаблон?

Объектная модель MigraDoc создаёт новый документ, а не редактирует существующий макет. Фоновую страницу или бланк можно подключать через PDFsharp и рисовать поверх, но интерактивные поля и сложное изменение содержимого требуют PDFsharp или специализированной библиотеки.

Можно ли вставить HTML?

Полноценного встроенного HTML/CSS-движка нет. Для ограниченного набора тегов пишут преобразователь в Paragraph, FormattedText, List и Table либо используют стороннее расширение. Для страниц со сложным CSS выбирают браузерный HTML-to-PDF-движок.

Как сделать повторяемую шапку таблицы?

У первых строк таблицы устанавливают HeadingFormat = true. Все такие строки должны идти подряд от начала таблицы. Для группы из двух строк свойство ставят обеим.

Как вывести страница X из Y?

В абзац колонтитула последовательно добавляют обычный текст, PageField, снова текст и NumPagesField. Значения определятся во время компоновки PDF.

Почему результат отличается между Windows и Linux?

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

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

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

Минимальный документ Hello World, созданный MigraDoc

Списки, маркеры и многоуровневая структура

Маркированные и нумерованные списки строятся на основе абзацев и ListInfo. Для каждого уровня задают тип списка, продолжение предыдущей нумерации и левый отступ. Такой механизм подходит для условий договора, перечня требований и пошаговой инструкции. Список остаётся частью потока и переносится между страницами вместе с обычными абзацами.

var item = section.AddParagraph("Проверить исходные данные");
item.Style = "BodyList";
item.Format.ListInfo.ListType = ListType.BulletList1;

item = section.AddParagraph("Сформировать документ");
item.Style = "BodyList";
item.Format.ListInfo.ListType = ListType.BulletList1;
item.Format.ListInfo.ContinuePreviousList = true;

Нумерация не должна зависеть от текста, вставленного между пунктами. Если между элементами списка помещён обычный абзац, продолжение нужно задавать явно. Для вложенного уровня выбирают другой ListType и увеличивают отступ. Пробелы и ручные символы • дают визуально похожий результат, но не обеспечивают единое выравнивание и корректное продолжение номера.

Маркер использует шрифт и символ, настроенные в параметрах MigraDoc. Поэтому резолвер должен уметь найти не только основной шрифт текста, но и гарнитуру маркера. Если квадрат или кружок заменился пустым прямоугольником, проверяют набор глифов и настройки предопределённых bullet fonts.

Для юридических документов полезно создать отдельные стили для первого, второго и третьего уровня. В них фиксируют выступ первой строки, отступ всего абзаца, расстояние после пункта и KeepTogether. Тогда длинный пункт переносится как обычный текст, а номер остаётся в правильной позиции.

Цвета, линии и единая тема оформления

Цвет задаётся объектом Color или предопределёнными значениями Colors. В шаблоне лучше хранить корпоративную палитру в одном классе и использовать её для текста, границ, заливки шапок и диаграмм. Если в каждом методе записывать отдельное RGB-значение, даже небольшое обновление фирменного цвета потребует поиска по всему проекту.

static class ReportTheme
{
    public static readonly Color Accent = Color.FromRgb(41, 82, 132);
    public static readonly Color HeaderFill = Color.FromRgb(231, 237, 245);
    public static readonly Color Border = Color.FromRgb(160, 170, 184);
    public static readonly Unit ThinLine = Unit.FromPoint(0.5);
}

Тонкая линия на экране может почти исчезнуть при печати на офисном принтере. Для таблиц и разделителей проверяют не только PDF-просмотрщик, но и бумажный вывод. Слишком светлая заливка также теряется на экономичном режиме печати. Контраст важнее декоративной близости оттенков.

Прозрачность и специальные режимы наложения удобнее добавлять через PDFsharp после компоновки. Объектная модель ориентирована на типографику документа, поэтому сложный полупрозрачный водяной знак, поворот и рисование по диагонали выполняют на уровне страницы. При этом основной текст остаётся построенным MigraDoc.

Тема должна учитывать чёрно-белую печать. Если различие статусов передаётся только красным и зелёным цветом, в сером варианте они могут стать одинаковыми. Добавляйте текстовую метку, форму маркера или различную штриховку, а не полагайтесь исключительно на цвет.

Метаданные и параметры открытия PDF

Document.Info хранит заголовок, тему, автора и ключевые слова. Эти значения попадают в свойства PDF и помогают идентифицировать файл в документном хранилище. Метаданные следует заполнять из проверенных полей модели, не вставляя внутренние комментарии, пути и служебные идентификаторы, которые не предназначены получателю.

document.Info.Title = $"Отчёт за {model.PeriodName}";
document.Info.Subject = "Финансовые показатели";
document.Info.Author = model.OrganizationName;
document.Info.Keywords = "отчёт; финансы; показатели";

После создания PdfDocument можно указать рекомендуемый режим отображения: одна страница, разворот, подгонка окна и другие ViewerPreferences. Это только подсказка для программы просмотра; пользовательские настройки могут иметь приоритет. Нельзя рассчитывать, что установка FitWindow гарантирует одинаковый масштаб во всех просмотрщиках.

Закладки PDF и внутренние ссылки улучшают навигацию по длинному отчёту, но не заменяют логичную структуру заголовков. Получатель может распечатать файл или открыть его в средстве, где панель закладок скрыта. Названия разделов и номера страниц должны оставаться понятными без интерактивных функций.

При создании файлов для долговременного хранения нужно отдельно определить требования к PDF/A, шрифтам, цветовым профилям и метаданным. Обычный вызов Save не следует автоматически считать подтверждением соответствия стандарту долговременного хранения. Соответствие проверяют специализированным валидатором и набором тестовых документов.

Локализация и документы на нескольких языках

Текстовые ресурсы заголовков, подписей таблиц и сообщений лучше хранить отдельно от кода компоновки. Генератор получает объект локализации или уже подготовленные строки. Тогда один и тот же метод AddItemsTable создаёт русскую, английскую или немецкую форму без копирования всей логики таблицы.

Разные языки имеют разную среднюю длину слов. Английская подпись может быть короче русской, а немецкая — заметно длиннее. Ширины колонок и размер шрифта проверяют на каждой поддерживаемой локали. Автоматическое обрезание текста без согласованного правила недопустимо для реквизитов и юридических формулировок.

Числа, даты и валюты форматируют с CultureInfo, соответствующей документу, а не процессу сервера. Знак валюты, разделитель тысяч и порядок дня и месяца должны быть частью требований шаблона. Если документ объединяет данные из нескольких стран, формат указывают рядом с показателем, чтобы получатель не трактовал число неоднозначно.

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

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

Доступность и качество чтения

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

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

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

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

Продвинутые приёмы для больших таблиц

Когда реестр группируется по клиенту, отделу или дате, удобно добавлять строку группы с объединением всех колонок, затем одну или несколько строк данных. Первая строка группы получает KeepWith, чтобы не остаться внизу страницы без данных. Итог группы оформляют отдельным стилем и не смешивают с повторяемой шапкой.

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

for (var index = 0; index < rows.Count; index++)
{
    var row = table.AddRow();
    if (index % 2 == 1)
        row.Shading.Color = Color.FromRgb(246, 248, 251);

    AddRowValues(row, rows[index]);
}

Если одна запись содержит длинное примечание, её можно представить двумя строками: основная строка с числовыми полями и следующая строка с объединённой ячейкой примечания. KeepWith связывает их. Такой макет устойчивее, чем попытка поместить большое описание в узкую колонку рядом с цифрами.

Для отчёта с сотнями страниц полезно добавлять в колонтитул идентификатор фильтра и дату формирования, а в начале — сводку параметров выборки. Это позволяет понять происхождение распечатанной страницы, даже если она отделилась от файла. Конфиденциальные параметры при этом не должны раскрывать больше, чем необходимо.

Если таблица должна продолжаться в другом приложении для анализа, не пытайтесь превратить PDF в замену электронной таблицы. Вместе с отчётом формируйте CSV или XLSX из того же набора данных, а PDF оставляйте представлением для чтения и печати. Это исключает необходимость извлекать числа из визуальной раскладки.

Развёртывание в контейнере и на сервере

Образ контейнера должен включать файлы шрифтов, изображения шаблона и нативные зависимости, которые требуются выбранной сборке. При публикации проверяют регистр имён файлов: путь, работающий в Windows, может не найти Logo.png на файловой системе, где logo.png считается другим именем.

Каталог приложения часто доступен только для чтения. Генератор не должен сохранять временный PDF рядом со сборкой. Для временных данных используют системную папку, выделенный том или полностью потоковый сценарий. Права на папку проверяют при запуске, а не после первого пользовательского запроса.

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

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

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

Итоговый рабочий подход

Устойчивый проект MigraDoc начинается с чёткой модели данных, единого набора шрифтов и стилей, затем строит документ из секций и потоковых элементов, а абсолютное позиционирование использует только для фиксированных деталей. Таблицы получают рассчитанные ширины, повторяемые шапки и тесты на длинные значения; заголовки — KeepWithNext; колонтитулы — явную настройку наследования.

Перед выпуском проверяют PDF на минимальных и максимальных данных, сравнивают страницы с эталоном, контролируют метаданные, размер и время генерации. Отдельно тестируют целевую операционную среду и наличие шрифтов. Такой процесс превращает кодовый шаблон в предсказуемый производственный инструмент, который одинаково формирует единичный акт и массовый пакет отчётов.

MigraDoc особенно хорошо раскрывается там, где содержание часто меняется, а структура остаётся стабильной: счёт получает новые позиции, отчёт — новые периоды, каталог — новые товары, но стили и правила компоновки едины. При задачах импорта HTML, редактирования существующих PDF или визуального дизайна шаблонов лучше заранее выбрать дополняющий инструмент, а не усложнять объектную модель обходными решениями.