Инженер гарантийного отдела выходит на осмотр квартиры. По результатам он должен сформировать соответствующий требованиям документ: акт в 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

Заполнять такие шаблоны нужно из кода, и список требований у нас был конкретный:

  1. подстановка значений с сохранением форматирования,

  2. клонирование строк таблиц, размножение таблиц по страницам,

  3. удаление пустых блоков вместе с вёрсткой,

  4. колонтитулы.

    Мы написали под это 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, но пока порядок держится на комментарии-предупреждении. Перестановка двух параметров компилируется молча — и молча же меняет отчёт.

Выводы получились такие.

  1. Шаблон должен оставаться документом, который правят в родном редакторе. Все удачные решения в проекте — следствия этого требования.

  2. Content control — адресуемый узел дерева, текстовый плейсхолдер — подстрока в чужом XML. Первый переживает правки людей, второй — нет.

  3. Условный рендеринг в Word — это удаление узлов. Оборачивайте в SDT всё, что может исчезнуть: строку, таблицу, страницу.

  4. Любой кусок URL, который едет в лейбл метрики, должен проходить allowlist.

  5. Фотографии врут о своей ориентации — AutoOrient с первого дня, а не после бага.

Комментарии (0)