Инженер гарантийного отдела выходит на осмотр квартиры. По результатам он должен сформировать соответствующий требованиям документ: акт в Word с шапкой, таблицей, подписями и приложением, в котором каждая точка на каждом фото пронумерована. Таких шаблонов в работе десятки: акты в Word, Excel-отчёты.
Меня зовут Дима, я разработчик в Sminex. Для ежедневных задач мы регулярно используем цифровые продукты, разработанные полностью инхаус, один из них — Warranty. Это веб-приложение, которое автоматизировало работу отдела гарантийных обязательств. Расскажу о том, как мы смогли сделать этот инструмент универсальным, то есть подходящим для разных видов актов и отчётов — печатью документов в нём занимается отдельный сервис на .NET 8. Почему шаблоны — это обычные .docx с content controls, а не текст с {{плейсхолдерами}}, зачем мы написали под это шаблонизатор, как пустой список данных удаляет из документа целую страницу и почему кусок URL нельзя пускать в лейбл Prometheus без белого списка, читайте в статье.
Зачем нам собственный продукт и почему документ должен остаться документом
Мы запустили Sminex Warranty для сопровождения проекта на различных этапах: от подготовки и передачи помещений клиентам до обработки обращений в течение гарантийного срока. При этом нужно было сделать все процессы прозрачными и удобными для сотрудников, упростить взаимодействие с подрядчиками, наладить бесшовную интеграцию приложения с другими системами компании.
Одним из самых сложных аспектов здесь стала работа с шаблонами форм. Каждая печатная форма — это юридический документ, разработчик не может его менять. Заказчик, в свою очередь, не может знать технических деталей и ограничений. Отсюда уже на старте возникло главное архитектурное требование — шаблон должен остаться обычным .docx, который можно открыть в Word, поправить формулировку, подвинуть колонку — без единой строчки кода, а собранные по всем шаблонам данные должны в дальнейшем аккумулироваться на единой платформе, чтобы их анализировать, например, выявлять повторяющиеся случаи и заранее устранять в новых проектах.
До внедрения приложения инженеры готовили документы вручную: кто-то каждый раз заполнял всё с нуля, кто-то копировал предыдущий документ и вносил изменения. На заполнение одного формата уходило до 60 минут, из-за человеческого фактора всегда возникал риск ошибки. Отметки на фото тоже делались полностью вручную. Теперь акты осмотра мы создаём через Warranty в автоматическом режиме, наши шаблоны лежат в репозитории как контент-файлы и деплоятся вместе с сервисом. Рассказываю, как нам это удалось.
Почему не {{плейсхолдеры}}
Первое, что приходит в голову — это написать в документе {{ClientName}}, распаковать docx (это zip с XML внутри) и пройтись заменой по document.xml. Подход умирает на первом же живом шаблоне.
Word хранит текст в run'ах — элементах <w:r> — и режет их непредсказуемо: сработала проверка орфографии, документ правили в несколько заходов, кто-то переключил форматирование посреди слова. Тот же {{ClientName}} в XML запросто выглядит так:
<w:r><w:t>{{Client</w:t></w:r> <w:proofErr w:type="spellStart"/> <w:r w:rsidR="00B2288E"><w:t>Name</w:t></w:r> <w:proofErr w:type="spellEnd"/> <w:r><w:t>}}</w:t></w:r>
Никакая регулярка это не найдёт. Можно склеивать соседние run'ы перед заменой — многие обёртки над OpenXML так и делают, но это работает ровно до тех пор, пока внутри плейсхолдера не окажется граница форматирования. А она окажется, потому что шаблоны правят люди в Word, а не генераторы.
Content control — плейсхолдер, который не рвётся
В Word давно есть штатный механизм под эту задачу: элементы управления содержимым — content controls, в XML они же SDT (structured document tag). Включаем вкладку «Разработчик», вставляем элемент «форматированный текст», в свойствах задаём Tag. Вот фрагмент нашего боевого act.docx (сокращено):
<w:sdt> <w:sdtPr> <w:alias w:val="ProjectLocation"/> <w:tag w:val="ProjectLocation"/> <w:id w:val="1304202059"/> </w:sdtPr> <w:sdtContent> <w:p> <w:r> <w:rPr> <w:rFonts w:ascii="Times New Roman"/> <w:sz w:val="24"/> </w:rPr> <w:t>{ProjectLocation}</w:t> </w:r> </w:p> </w:sdtContent> </w:sdt>
Что здесь важно:
узел адресуется по
w:tag— как бы Word ни крошил текст внутри на run'ы, граница элемента стабильна,{ProjectLocation}внутри — видимая заглушка для человека, открывшего шаблон, код на неё не смотрит,форматирование (Times New Roman, 12 pt) живёт в шаблоне — сервис подставляет голый текст, стиль остаётся тем, который задали в Word.
И главное, что content control оборачивает что угодно — слово, абзац, таблицу, несколько страниц подряд. На этом дальше будет держаться вся условная логика документа.
Свой шаблонизатор: Sminex.DocxProcessor
Заполнять такие шаблоны нужно из кода, и список требований у нас был конкретный:
подстановка значений с сохранением форматирования,
клонирование строк таблиц, размножение таблиц по страницам,
удаление пустых блоков вместе с вёрсткой,
-
колонтитулы.
Мы написали под это Sminex.DocxProcessor — внутренний NuGet-пакет на 2 700 строк поверх DocumentFormat.OpenXml.
Снаружи он выглядит так:
DocxContent content = new DocxContentBuilder() .SetFileName("Акт осмотра.docx") .SetFieldsContent( ("ProjectLocation", "г. Москва"), ("EngineerFullName", "Иванов И. И.")) .SetTablesContent(issuesTable, signaturesTable) .Build(); using var processor = DocxProcessor.Open("act.docx", isEditable: false); DocxStream stream = processor.Execute(content);
Внутри — три обработчика (поля, таблицы, изображения) и несколько решений, которые оказались важными.
Шаблон никогда не открывается на запись. При Open все части пакета клонируются в новый документ поверх MemoryStream, и дальше вся работа идёт с копией. Файл шаблона на диске всегда read-only, параллельные запросы к одному шаблону не мешают друг другу.
Замена значения сохраняет форматирование. Для строчного контрола клонируется run из содержимого вместе с его RunProperties, для блочного — ещё и ParagraphProperties родительского абзаца. А перевод строки превращается в <w:br/> — элемент <w:t> не понимает \n, и без этого многострочный адрес печатался бы в одну строку:
string[] rows = text.Text.Replace("\r\n", "\n").Split('\n'); for (int i = 0; i < rows.Length; i++) { parent?.AppendChild(new Text(rows[i])); if (i < rows.Length - 1) { parent?.AppendChild(new Break { Type = BreakValues.TextWrapping }); } }
Колонтитулы — отдельный мир. В акте осмотра помещения таблица подписей по макету живёт в футере, и document.xml про неё не знает вообще. Поэтому обработка идёт отдельно по MainDocumentPart, HeaderParts и FooterParts. Еще и сам Word создаёт в футере SDT без тега — под нумерацию страниц. Для них библиотека возвращает пустое имя тега вместо исключения; в коде это зафиксировано отдельным комментарием, чтобы никто не «починил» обратно.
Финальная зачистка. Последний шаг Execute — развернуть все оставшиеся SDT, оставить содержимое, а обёртку удалить. Получатель открывает обычный документ без рамок content control'ов и следов шаблонизации.
Таблицы: клонируем строки, таблицы и целые страницы
С одиночными полями всё понятно, интересное начинается в таблицах. Механик три.
Строка-прототип. Последние N строк таблицы шаблона объявляются прототипом, и, пока есть данные — прототип клонируется и заполняется. N настраивается. Таблица подписей, например, двухстрочная — ФИО плюс линейка для подписи, поэтому SetRowsToClone(2).
Генерируемые таблицы. Приложение с фотографиями — это не строки одной таблицы, а отдельная таблица на каждую фотографию: номер замечания, описание, снимок. Библиотека клонирует таблицу целиком по числу элементов, а для документа со списком дефектов каждый клон ещё и начинается с новой страницы — флаг PageBreakBefore.
Пустые данные удаляют вёрстку. У каждой таблицы есть политика на случай пустых данных: удалить строку, удалить таблицу или удалить таблицу вместе со страницей. Для последнего в шаблоне целая страница обёрнута в content control с тегом AttachmentsPage:
List<IssueAttachmentDto> attachments = []; // Если флаг true, то прикрепляем вложения, в противном случае - передаем пустой список, // что приводит к удалению таблицы из шаблона. if (isIncludePhotos) { attachments = await _attachmentService .GetIssueAttachmentsAsync(data.Issues.Value, jwtToken, cancellationToken); } TableContent attachmentsTableContent = new TableContentBuilder() .SetTableName(AttachmentsTable) .SetRemoveEmptyTableWithPage(AttachmentsPage) .SetIsGeneratedTable() .SetGeneratedTablesContent(generatedContent) .Build();
Пользователь не запросил фотографии → сервис передал пустой список → из документа исчезла целая страница вместе с заголовком и таблицей. Условный рендеринг в Word — это удаление узлов дерева, и проектируется он ещё на этапе вёрстки, где заранее решаем, что во что оборачивать.
Есть и обратная задача. В актах бывают поля для заполнения от руки — пустые линейки. Если решение комиссии уже внесено в систему, таблица с линейками удаляется и печатается текст; если нет — линейки остаются, и акт дозаполнят ручкой.
Три истории из сервиса
Шаблон, которого не существует
Частичный акт устранения замечаний отличается от полного тем, что в него попадают только устранённые замечания. Файла partial_auz.docx в проекте нет:
public static string GetTemplatePath(string subdirectory, string templateName, string extension) { // Частичный акт устранения замечания идентичен акту устранения замечания в контексте шаблона. if (extension.EqualsIgnoreCase(DocxExtension) && CheckTemplateNameIsPartialAuz(templateName)) { templateName = EDocxTemplate.Act.ToString().ToLower(); } // ... }
PathHelper молча подставляет act.docx, а сервис данных фильтрует замечания по статусу «Устранено». Новый вид документа появился без нового шаблона и без нового сервиса путём добавления значения в enum и всего двух if.
Один запрос - тринадцать ленивых проекций
Данные для акта прилетают одним запросом к бэкенду Warranty. Но каждый шаблон использует свой поднабор полей, в котором где-то нужен список собственников с инициалами, где-то представитель по доверенности, где-то ни то ни другое. Поэтому DTO собирается с Lazy<T> прямо в позиционных параметрах record'а:
OgoDataForDocDto dataForDoc = new( ActName: act.ActName, // ...обычные поля... EngineerFullName: new Lazy<string>(() => TextParserService.GetFullName( act.EngineerLastName, act.EngineerName, act.EngineerMiddleName)), ClientsListing: new Lazy<string>(() => TextParserService.GetClientsListing(act.Clients)), AttorneyInfo: new Lazy<string>(() => TextParserService.GetAttorneyInfo(act.Attorney?.Name, attorneyDate, false)), OwnerNames: new Lazy<string>(() => TextParserService.GetOwnerNamesOrRepresentativeByAttorney(act)), // ...ещё девять таких же... );
Сервис данных конкретного шаблона дёргает .Value только у того, что реально есть в его вёрстке. Тринадцать форматирующих проекций, из которых для конкретного акта выполнится нужная часть, а остальные так и останутся несозданными строками.
Фотография с точками замечаний
Инженер отмечает замечания точками на фотографии в веб-интерфейсе, бэкенд хранит нормализованные координаты от 0 до 1. Печатная форма обязана воспроизвести ровно ту же картинку, что и на фронте — чёрные пронумерованные точки с белой обводкой.
Фото скачивается по presigned-ссылке (её выдаёт бэкенд Warranty — у принт-сервиса нет ключей от S3) и попадает в ImageSharp:
using Image image = await Image.LoadAsync(imageStream, cancellationToken); // Учитывает метадату ориентации изображения и устанавливает значения // высоты и ширины от фактических границ. image.Mutate(context => context.AutoOrient());
AutoOrient - строчка, за которой стоит отдельный фикс, потому что телефоны пишут ориентацию в EXIF, Word на EXIF не смотрит, и без неё вертикальные фотографии падают в акте на бок.
Дальше рисуются точки. Белая подложка чуть больше чёрной точки — это обводка, без которой номер теряется на тёмном фоне. Размеры заданы не в пикселях, а через «сколько точек поместится в ширину кадра»: 20 основных и 19 подложек — отсюда и разница диаметров. Всё это умножается на отношение фактической ширины картинки к максимальной, поэтому точка печатается одного физического размера и на фото с телефона, и на скриншоте плана этажа.
Номер точки пишется шрифтом из demi.woff2 — да, веб-шрифт в бэкенд-сервисе. Это та же гарнитура, что на фронте, где печать обязана совпадать с тем, что инженер видел на экране.
Отдельная валюта этого мира — EMU (English Metric Units), внутренние единицы Word, а именно 914 400 EMU на дюйм. ImageSharp считает в пикселях, Word — в EMU, максимум для фото в акте — 5 500 000 × 4 500 000 EMU, примерно 15 × 12 см. Конвертация закреплена юнит-тестами на точные значения.
И крайне важное правило, что битая фотография не должна убивать весь акт. Presigned-ссылки протухают, хранилище моргает — поэтому каждая загрузка обёрнута в TryGetImageOrNullAsync: не скачалось — warning в лог и акт без этой фотографии, а не 500 на весь документ.
Excel: шаблон рисуется в Excel
С отчётами философия та же, .xlsx свёрстан руками — ширины колонок, шапка, форматы уже в файле шаблона. ClosedXML только льёт данные и навешивает формулы:
using XLWorkbook workbook = new(filePath); IXLWorksheet worksheet = workbook.Worksheet(SheetName); // Имя листа - сегодняшняя дата. worksheet.Name = TimeZoneService.GetOpgSheetDateName(tzOffset); worksheet.Cell("A2").InsertData(tableRows); IXLTable? table = worksheet.Range($"A1:AF{rowsCount + 1}").CreateTable();
CreateTable — не косметика, в нём диапазон становится настоящей Excel-таблицей, и в формулах начинают работать structured references. Сами формулы лежат в C#-константах:
/// <summary> /// Формула A1 для ячейки "Выполнить в срок до". /// </summary> public const string FormulaA1CellR = "@[Дата поступления заявки]+45"; /// <summary> /// Формула R1C1 для ячейки "Просрочено". /// </summary> public const string FormulaR1C1CellV = "IF(RC[-4]>RC[-1],\"-\",IF(RC[-3]=\"-\",RC[-1]-RC[-4],\"-\"))";
Кириллическое имя колонки прямо в формуле, 45-дневный SLA в строковой константе, R1C1-нотация для относительных ссылок, формула присваивается на весь диапазон, Excel сам раскатывает её по строкам. Отчёт после выгрузки продолжает жить своей жизнью — TODAY() в колонке «Дата отчёта» пересчитывается при каждом открытии файла.
Мой любимый трюк здесь — футер реестра. Блок подписей свёрстан на отдельном служебном листе шаблона, код копирует его на вычисленную позицию после последней группы данных и удаляет служебный лист:
IXLWorksheet? footerTemplateWorksheet = workbook.Worksheet(FooterTemplateSheetName); IXLRange footerRange = footerTemplateWorksheet.Range("A1:F7"); const int spaceBetweenFooterAndRows = 3; footerRange.CopyTo(worksheet.Cell($"A{currentRowPosition + spaceBetweenFooterAndRows}")); footerTemplateWorksheet.Delete();
Вёрстка остаётся вёрсткой — блок можно править в Excel, — а позицию считает рантайм.
Маппинг строк при этом держится на честном слове, так как порядок колонок в отчёте — это порядок позиционных параметров record'а на 32 поля, и защищает его только предупреждение в XML-doc: «Порядок полей в модели влияет на порядок в документе». Решение не самое приятное, но держится за счёт покрытия тестами.
Лейбл из URL
Роут генерации: GET print/v1/word/{template}/{actId}. Мы хотим видеть длительность генерации в разрезе шаблонов — значит, {template} должен попасть в лейбл Prometheus-метрики. Но {template} — это строка из URL, которую контролирует клиент, где каждый GET /print/v1/word/lol123/... рождал бы новую time series. Взрыв кардинальности — не от бага, а от любого праздного сканера.
Поэтому в лейбл попадают только значения из белого списка:
private static readonly FrozenSet<string> KnownTemplates = Enum.GetNames<EDocxTemplate>() .Concat(Enum.GetNames<EXlsxTemplate>()) .ToFrozenSet(StringComparer.OrdinalIgnoreCase); private static string NormalizeTemplate(string template) { return KnownTemplates.Contains(template.Replace("_", string.Empty)) ? template.ToLowerInvariant() : UnknownTemplateName; // всё неизвестное схлопывается в "unknown" }
FrozenSet — иммутабельное множество, оптимизированное под сценарий «собрали один раз на старте, читаем на каждом запросе». Replace("_", ...) сводит роутовый стиль mop_aop к enum-стилю MopAop.
Второй штрих — границы гистограммы. Дефолтные бакеты OpenTelemetry под генерацию документов не подходят, поэтому явная лестница с удвоением - десять бакетов от 10 мс до 5,12 с:
double[] boundaries = [0.01, 0.02, 0.04, 0.08, 0.16, 0.32, 0.64, 1.28, 2.56, 5.12];
Что мы планируем доработать
Это живой проект, поэтому мы продолжаем смотреть, что сделать ещё лучше.
Формулы приколочены к буквам колонок. Константы называются FormulaA1CellR и ConditionFormulaCellW — по физическим колонкам листа. Однажды в отчёт добавилась одна колонка — и в одном коммите пришлось переименовать половину констант и переписать все диапазоны ("G1, K1, M1, N1" → "H1, L1, N1, O1"). Между бизнес-колонкой и буквой листа просится слой-маппинг.
N+1 к хранилищу. Вложения качаются последовательно, отдельным запросом на каждое замечание — в коде на этом месте лежит честный TODO. На акте с десятками фотографий генерация заметно дольше, чем могла бы быть.
Порядок колонок — порядок параметров record'а. В ClosedXML есть XLColumnAttribute, но пока порядок держится на комментарии-предупреждении. Перестановка двух параметров компилируется молча — и молча же меняет отчёт.
Выводы получились такие.
Шаблон должен оставаться документом, который правят в родном редакторе. Все удачные решения в проекте — следствия этого требования.
Content control — адресуемый узел дерева, текстовый плейсхолдер — подстрока в чужом XML. Первый переживает правки людей, второй — нет.
Условный рендеринг в Word — это удаление узлов. Оборачивайте в SDT всё, что может исчезнуть: строку, таблицу, страницу.
Любой кусок URL, который едет в лейбл метрики, должен проходить allowlist.
Фотографии врут о своей ориентации —
AutoOrientс первого дня, а не после бага.