Редактируем штампы для PDF за 30 секунд без публикации пакета, с помощью… усов

40 0

Программист с усами — сила.
А программист с Mustache
— хоть три подписи в штамп вставит
Moustache - англ. усы),
Mustache - библиотека
шаблонизации.

 

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

Но однажды Вы понимаете, что 2025 год уже наступил, а код для штампов живет по законам средних веков: HTML код штампа зашит в ресурсах, доступных штампов— ровно два, как глаза у человека (а если нужно три — извините, мутируйте?), вывод нескольких подписей на странице потребует навыков шамана-программиста.

И тогда вы задаетесь вопросом: «А что, если можно иначе? Просто… нажать пару кнопок?».
Спойлер: можно, более того - у некоторых из наших заказчиков это уже работает именно так. 

 

Как это начиналось в СТАРКОВ Групп

Моим самым первым заданием, когда я пришел работать в СТАРКОВ Групп, было: реализовать штамповку “Согласовано юридическим отделом” в одном из регламентов. На старте задача показалась довольно простой:  думал, что «штамповать PDF» — не сложнее чем «нажать кнопку и пойти пить кофе».

Но я только что закончил 815 курс, впервые столкнулся с Directum RX, Aspose, пытался понять “что за ЯКОРЬ ⚓?”. Да, получилось, я всё ещё работаю в СТАРКОВ Групп, но первые впечатления самые сильные, так что штамповка PDF теперь одна из моих любимых задач. После этого на других проектах требовались разные доработки - накладывать информацию о нескольких согласующих, выводить дополнительную информацию в штампе, накладывать штамп регистрации на уже подписанный документ еще до того, как это реализовали в коробке, и так далее. В общем по доработке штампов у меня приличный опыт на данный момент - начиная с RX 3.3 и заканчивая RX 4.12, так что когда у меня начнётся мания величия, попрошу меня называть “Мастер над штампами”.

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

 

Как мы ставили штампы раньше, или почему HTML в ресурсах — это не удобно, немного матчасти

Сразу предупреждение – в 4.12 вендор внёс много изменений в код штамповки, поэтому теперь есть две ветки кода, которые нужно перекрывать. Для упрощения кода, чтобы показать основную логику – используем только ветку со старым кодом штамповки, поэтому в таблице sungero_docflow_params строка с UseObsoletePdfConversion должна быть true.

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

По настройке позиционирования всё гораздо лучше: в данный момент в системе  есть возможность указывать координаты штампа по якорям в тексте (⚓^) или по координатам (решения для государственных органов) или просто разместить его на странице мышкой (чудеснейшее дополнение от разработчиков Директум в версии 4.12, кстати, идею подал наш разработчик в статье — https://club.directum.ru/post/368757 ).

Всего в системе есть 2 варианта штампа ЭП – для ПЭП и УКЭП/УНЭП и один для штампа регистрации:

 
и

 Текущие проблемы:

  1. Код HTML штампа хранится в ресурсах модуля Sungero.Docflow, поэтому редактирование невозможно без помощи разработчика, при любой правке уже потребуется перекрывать модуль и перекрывать функцию, которая с этим работает.
  2. Любая правка кода штампа в ресурсах публикуется несколько минут на стенде разработчика, а чтобы отправить изменение цвета шрифта на прод потребуется традиционный круг почета - согласование, планирование, бэкап, публикация, тест.
  3. Вставка логотипа безболезненно возможна только в коробочный HTML. Потому что по странному архитектурному решению - разработчики вставляют не тэг IMG в нужное место, а ячейку таблицы целиком (тэги TD, IMG) со всем оформлением, что уже не позволяет вставить логотип в шаблоне без дополнительного перекрытия функций. Аналогично с теми же проблемами вставляется строка с датой.
  4. Регистрационный штамп изначально вставляется по координатам, прицелиться по якорю нельзя.
  5. Прямо в коде жесткое ограничение – ровно два штампа - «если это штамп ЭП, то наложить штамп ЭП, иначе наложить регистрационный штамп».

Сразу напрашиваются варианты развития:

  1. Вынести максимум настроек штампов в справочник, чтобы его мог редактировать администратор.
  2. Реализовать возможность наложения нескольких разных типов штампов на достаточно большое количество якорей, а уж их в шаблон добавить не проблема.
  3. Довольно часто заказчики просят – накладывать и согласующие подписи, а их может быть несколько – поэтому реализовать множественный штамп ЭП.
  4. Ну и личное пожелание - всегда все штампы должны накладываться разом, без доштамповки в несколько заходов, опираясь только на текущее состояние документа/задачи.

 

Nustache, Aspose и шаблоны: как мы научили штампы быть гибкими

У меня есть недостаток – если уж начал
что-то модифицировать, то сложно остановиться.
В итоге как обычно – получился универсальный
комбайн с вертикальным взлётом и встроенным
лунным посадочным модулем.

 Моя душа (в прошлом) full-stack разработчика не могла согласиться с ключевой вещью – если HTML код штампа представляет из себя простой текст, зачем использовать настолько кондовые методы работы с текстом, когда есть заметно более гибкие методы, например взять и отрастить усы…

Суть шутки заключается в том, что есть язык разметки Mustache («усы» в переводе), который отлично работает с шаблонами и отлично умеет подставлять внешние переменные в код шаблона, что позволяет удобно работать с шаблонами в HTML.

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

В случае с Mustache язык разметки позволяет:

  • просто указать переменную – {{name}}, иногда лучше использовать {{& name}} или {{{name}}} – тогда можно будет помещать в переменную и HTML тэги без ограничений
  • поля сложных объектов – {{client.name}}, корректно обрабатывает свойства объектов, но есть ограничения на глубину вложенности
  • добавить условный вывод – {{#isVisible}} А это будет выведено при условии{{/isVisible}}
  • добавить списки – {{#loop}}<div>{{item}}</div>{{/loop}}

Собственно, это все особенности языка разметки, дальше остаётся только настроить шаблоны и написать функции сборки необходимых переменных.

Конечно, хотелось бы сделать настройку переменных в таком виде:

Но эти компоненты не доступны прикладному разработчику, поэтому пойдём более простым путём (в лучших традициях бэкенда) – каждый документ будет заполнять коллекцию Dictionary<string,object> с необходимыми значениями переменных, она же годится и для вложенных списков, главное не передавать такое через Remote функции и работать строго внутри своего серверного слоя. При необходимости исходный код будет достаточной документацией :)

А дальше остаётся сделать все необходимые перекрытия и написать достаточно удобную обвязку.

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

Первый простой этап – боремся с ограничениями на настройки штампов. Для этого потребуется перекрыть справочник «Настройка отметок в PDF» (StampSetting). В качестве простого решения была просто добавлена коллекция «Якорь» - «Шаблон» для хранения любого необходимого количества штампов и якорей для них. Заодно не забываем добавить отключаемость всего этого, чтобы при необходимости легко перейти на коробочные (чекбокс - "Использовать расширенные штампы").

Да, если не хватает фантазии – откуда взять дополнительные «якоря» - https://www.compart.com/en/unicode/U+2693 и просто смотреть соседние.

Перекрытие OfficialDocument

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

    /// <summary>
    /// Возвращает переменные для использования в PDF штампах, добавлять переменные в наследниках.
    /// </summary>
    public virtual System.Collections.Generic.Dictionary<string, object> GetStampVariables(IStampSetting stampSettings)
    {
      var vars = new Dictionary<string, object>();

      //Ключевая часть магии - шаблонизатор сам бегает по свойствам документа
      vars["doc"] = _obj;
      
      // Условная проверка - на факт регистрации {{#HasRegistered}} {{RegistrationDate}} {{/HasRegistered}}
      if (_obj.RegistrationDate != null)
      {
        vars["IsRegistered"] = new Dictionary<string, object>()
        {
          {"RegistrationDate", _obj.RegistrationDate.Value.ToShortDateString()},
          {"RegistrationNumber", _obj.RegistrationNumber
          }
        };
      }
      
      // Утверждающая подпись основного подписанта
      var signatures = PdfStamp.Module.Docflow.PublicFunctions.Module.GetSignaturesForMark(_obj, _obj.LastVersion?.Id ?? 0, false);
      if (signatures.Any())
      {
        var signatory = _obj.OurSignatory;
        
        //подпись подписанта, может быть может быть по замещению
        var signature = signatures.Where(x => x.SignatureType == SignatureType.Approval)
          .Where(x => signatory != null && (signatory.Equals(x.SubstitutedUser) || signatory.Equals(x.Signatory)))
          .OrderBy(x => x.SigningDate)
          .FirstOrDefault();
        
        if (signature != null)
        {
          var signatureStampParams = Sungero.Docflow.PublicFunctions.StampSetting.GetSignatureStampParams(stampSettings, signature.SigningDate, true);
          var approveVars = PdfStamp.Module.Docflow.PublicFunctions.Module.GetSignatureMarkForCertificateAsDict(signature, stampSettings);
          foreach(var key in approveVars.Keys)
            vars[key] = approveVars[key];
          
          signatures.Remove(signature);
        }
      }
      
      #region Дополнительные согласующие подписи для множественной штамповки.
      var approveSignatures = new List<Dictionary<string, string>>();
      foreach(var signature in signatures)
      {
        var signatureStampParams = Sungero.Docflow.PublicFunctions.StampSetting.GetSignatureStampParams(stampSettings, signature.SigningDate, true);
        var approveVars = PdfStamp.Module.Docflow.PublicFunctions.Module.GetSignatureMarkForCertificateAsDict(signature, stampSettings);
        approveSignatures.Add(approveVars);
      }
      
      if (approveSignatures.Any())
        vars["ApproveSignatures"] = approveSignatures;
      #endregion
      
      return vars;
    }

Причем здесь же и скрывается вторая главная часть магии работы с шаблонами - в строке «vars["doc"] = _obj;» - благодаря этому шаблонизатор получает возможность обращаться самостоятельно к любым свойствам документа. Это просто фантастика – если не требуется дополнительных преобразований (отрезать миллисекунды из даты регистрации, например) – можно просто добавить тэг в шаблон и всё.

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

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

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

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

    public virtual string RenderTemplate(string template, System.Collections.Generic.Dictionary<string, object> model)
    {
      if (string.IsNullOrEmpty(template) || model == null)
        return string.Empty;
     
      return Nustache.Core.Render.StringToString(template, model, 
                                                 new Nustache.Core.RenderContextBehaviour() {
                                                   OnException = ex => Logger.Error(ex.Message, ex) 
                                                 }).Trim();
    }

 

Как это работает: код, примеры и магия циклов

Логика работы в данный момент очень простая – настройки HTML для всех якорей вынесены в отдельную коллекцию в настройках отметок PDF.

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

Перебираем все якоря из настроек StampSetting, рендерим HTML код (RenderTemplate) для каждого якоря и отправляем в штамповку ConvertToPdfWithStampsCustom.

Пример HTML кода вывода обычного штампа ЭП (сокращено):

<html>
  <body>
    <div>{{{Logo}}} ЭЦП <b>{{{SignatoryFullName}}}</b></div>
    <div>Дата подписания: <b>{{& SigningDateTimeUtc}}</b></div>
    {{# Comment}}
        <div>Комментарий <b>{{.}}</b></div>
    {{/Comment}}
  </body>
</html>

Здесь из интересного:

  • Logo содержит HTML код, поэтому пришлось обернуть в {{{тройные}}} фигурные скобки, чтобы избежать преобразования HTML кода в текст в &lt; и т.д. (Mustache заботится, чтобы случайно не поломали вёрстку или не сделали дырку в безопасности).
  • Comment – условный блок, если комментарий есть – он будет отрисован, при этом {{.}} – ссылка на саму переменную, внутри блока которой мы находимся в данный момент.

Более интересный пример с множественными штампами (тоже сильно упрощенный):

<html>
  <body>
    <div style="width: 500px">
      {{#ApproveSignatures}}
        <div style="float: left; margin: 2px">
            ЭЦП <b>{{& SignatoryFullName}}</b> <br />
            <span class="tg1">
                Дата подписания: <b>{{& SigningDateTimeUtc}}</b>
            </span>
        </div>
      {{/ApproveSignatures}}
    </div>
  </body>
</html>

Здесь из интересного:

  • в массиве ApproveSignatures – содержатся все необходимые подписи.
  • <div style="width: 500px;"> - явно задаёт габариты контейнера, в пределах ширины которого выстраиваются подписи, без этого разъедется по всей ширине и не факт, что впишется в отведённое для штампа место.
  • <div style="float: left; margin: 2px"> - обеспечивает автоматическое позиционирование каждой новой записи о подписи, ширины хватает на две записи, без этого пришлось бы выстраивать все подписи в динамически генерируемую таблицу (огромное спасибо Aspose, что он справляется с рендером CSS).

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

<html>
  <body>
    <div style="width: 300px; background-color: yellow">
      Адресаты:
      <ul>
        {{# doc.Addressees }}
            <li>
                {{Correspondent}} 
                {{# Addressee}}({{.}}){{/ Addressee}}
            </li>
        {{/ doc.Addressees }}
      </ul>
    </div>
  </body>
</html>

Здесь из интересного:

  • Прямо из документа берём коллекцию адресатов -  doc.Addressees, для каждой записи – если заполнен контакт – мы его показываем, если не заполнен – текст не создаётся, этим управляет условный блок {{# Addressee}} ({{.}}) {{/Addressee}}

 

Что выиграли заказчики: гибкость и скорость настройки штампов

При необходимости теперь заказчик может настроить отображение штампа, добавив к нему название подразделения, нашей организации, ИНН, даты рождения подписанта и прочего – практически без обращения к разработчику. Более того, консультант, который прошел 816 курс – уже сможет посмотреть в среде разработки все необходимые имена свойств и собрать строку с необходимым именем самостоятельно после чтения страницы справки по Mustache.

Итоговый результат, которым я немного горжусь:

  1. Реализован No-code подход для использования новых свойств (немного страшный, но No-code)
  2. Новые типы штампов можно добавлять без помощи разработчика
  3. Количество штампов и условия простановки на документе можно изменять просто редактируя шаблон - добавляя/удаляя якоря
  4. Обеспечена максимальная гибкость по выводимым данным в штампах
  5. Изменения в штампах применяются без публикации пакета разработки, на лету
  6. Доступен большой простор по модификациям для правки логики под собственные задачи

Ну и плюс моё личное идеологическое - вызывая штамповку из любого места - мы всегда получаем одинаковый результат, основанный на текущем состоянии документа, мне не нравятся хрупкие цепочки доштамповки, когда случайной перегенерацией PublicBody могут затираться штампы, наложенные где-то в середине сложной цепочки заданий и сценариев. А на боевых проектах еще и убираем проверку на обязательное наличие подписи - если сотрудник ХОЧЕТ сделать PDF из документа - он МОЖЕТ это сделать, но в текущем примере это не показано.

Что можно дальше с этим сделать:

  1. Есть ограничения в получении связанных свойств, похоже что при обращении через рефлексию не срабатывает геттер для получения данных по связанному свойству, поэтому доступны только свойства на один уровень вложенности - сам документ и объекты по ссылке, но свойства самого ссылочного объекта уже не доступны, может быть это можно изменить, но пока готового решения нет. При необходимости можно по аналогии с vars["doc"] = _obj; докладывать дополнительные объекты, получится достаточно похоже на Low-Code.
  2. Можно в рамках конкретной задачи добавить настройки в справочник виды документов, чтобы более точно настраивать поведение, например использовать расширенную штамповку только для договорного процесса, тут обычная задача для прикладного разработчика.
  3. А в остальном решение уже и так почти замечательное, зачем?

 

Disclaimer

  1. Некорректно собранным HTML кодом можно сломать штамповку, поэтому лучше сначала тестировать в безопасных условиях, затем уже нести на продуктивный сервер.
  2. Код сильно упрощен в целях демонстрации, поэтому просьба не оценивать по нему мой уровень кодинга (обычно еще хуже 🙂).

Полный код решения можно подключить в свою среду разработки для версии RX 4.12 из нашего репозитория:

https://github.com/STARKOV-Group/PdfStampExample 

UPD 2025-04-03: 
Чтобы подключить решение в среде разработки для изучения - в файл _ConfigSettings.xml в каталоге среды разработки просто добавить:
<repository folderName="PdfStamps" solutionType="Base" url="https://github.com/STARKOV-Group/PdfStampExample.git"/> в раздел <block name="REPOSITORIES"> и перезапустить среду разработки. Можно использовать solutionType="Work", чтобы разработка оказалась на рабочем слое, но могут возникнуть конфликты с существующей разработкой, поэтому тут уже попрошу действовать аккуратней.

Ну или внести аналогичную правку в config.yml в DirectumLauncher, а потом запустить в cmd: do.bat dds config_up , в этом случае скрипты платформы самостоятельно обновят _ConfigSettings.xml

 

А это мультик, где я укладываюсь с редактированием штампа в 30 секунд. как и обещал в заголовке. Просьба не судить по общему виду штампов о красоте решения - просто типичная ситуация, когда бэкендер занимается фронтендом, да еще и без ТЗ (зато работает).

 

Пока комментариев нет.

Авторизуйтесь, чтобы написать комментарий