Простой шаблонизатор DOCX-документов с помощью Smart Document Engine

от автора

Мы в Smart Engines занимаемся системами распознавания документов, и мы решили проверить, сколько нужно времени, чтобы создать MVP инструмента, позволяющего предзаполнять типовые шаблоны в формате DOCX данными, извлекаемые из сканов и фотографий документов. В этой статье мы вам покажем как на базе нашей системы распознавания Smart Document Engine быстро сделать простой шаблонизатор, готовый к использованию и не требующий никакой предварительной подготовки пользователя. Кому интересно — добро пожаловать под кат!


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

Конечно, все эти операции можно и нужно автоматизировать. На помощь могут прийти, например, RPA-системы, с помощью которых пользователь должен лишь выполнить правильную последовательность действий по загрузке-выгрузке данных. Но для того, чтобы в компании RPA была внедрена, как правило, нужно выполнить целый большой проект, собрать нужное количество компонент, настроить их, научить пользователей пользоваться, и так далее и тому подобное. Что если пользователю нужно всего лишь сканировать набор типовых документов и заполнять банальные Word-овые документы с правильными реквизитами?

Мы покажем, как просто собрать минимальный, но функциональный шаблонизатор, используя Smart Document Engine и python с несколькими общедоступными пакетами. Манипуляции все будут демонстрироваться на примере SDK для MacOS, однако все то же самое заработает также и для Windows и для систем на базе Linux.

Распознавание документов с помощью Smart Document Engine

В основе шаблонизатора лежит, само собой, распознавание документов с помощью Smart Document Engine. Библиотека обладает рядом интерфейсов интеграции (основной интерфейс C++ и набор оберток), но, для максимальной простоты, функцию распознавания мы реализуем в виде CLI-приложения, используя в качестве основы С++-пример, который находится прямо в SDK-пакете.

Чтобы скомпилировать поставляемый консольный пример docengine_sample, нужно вставить в код подпись клиента, которая берется из документации SDK-пакета:

// Creating a session object - a main handle for performing recognition. std::unique_ptr<se::doc::DocSession> session(     engine->SpawnSession(*session_settings, “ABCDEFG….”));

После этого его можно собирать (например, лежащим рядом скриптом build_cpp.sh).

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

$ DYLD_LIBRARY_PATH=../../bin ./docengine_sample ../../testdata/rus.payment_order_sample.png ../../data-zip/bundle_docengine_photo.se "rus.payment_order*" Smart Document Engine version 1.11.0 image_path = ../../testdata/rus.payment_order_sample.png config_path = ../../data-zip/bundle_docengine_photo.se document_types = rus.payment_order*  (... Скрыто много дополнительной информации …)      Text fields (35 in total):         amount                    : 20 003 000-00         amount_words              : ДВАДЦАТЬ МИЛЛИОНОВ ТРИ ТЫСЯЧИ РУБЛЕЙ 00 КОПЕЕК         beneficiary               : ООО "МЕЧТА"         beneficiary_account       : 11223344556677889900         beneficiary_bank          : МЕЖДУНАРОДНЫЙ ЗАПАДНЫЙ БАНК         beneficiary_bank_invoice  : 33344455566677788899         bik_beneficiary           : 987654321         bik_payer                 : 012345678  (... и так далее …) 

Для целей создания шаблонизатора слегка модифицируем код приложения:

1. В конфигурационном бандле bundle_docengine_photo.se по умолчанию используется режим, оптимизированный для распознавания фотографий (в нашем демо-приложении этот режим используется при распознавания документов с фотографий, полученных непосредственно на устройстве). Выставим сессиям распознавания режим “universal”, который более подходит в случае, когда заранее неизвестно, скан будет распознаваться или фотография (в демо-приложении этот режим используется при распознавании из галереи):

session_settings->SetCurrentMode("universal"); // переходим в режим universal // For starting the session we need to set up the mask of document types //     which will be recognized. session_settings->AddEnabledDocumentTypes(document_types.c_str());

2. Уберем весь отладочный/информационный вывод и упростим функцию OutputRecognitionResult так, чтобы она выписывала тип и текстовые поля распознаванного документа в JSON-формате:

void OutputRecognitionResult(     const se::doc::DocResult& recog_result) {   if (recog_result.GetDocumentsCount() == 0) {     printf("{}\n");   } else {     const se::doc::Document& doc = recog_result.DocumentsBegin().GetDocument();     printf("{\"DOCTYPE\": \"%s\"", doc.GetAttribute("type"));     for (auto f_it = doc.TextFieldsBegin();          f_it != doc.TextFieldsEnd();          ++f_it) {       std::string escaped_value = std::regex_replace(           f_it.GetField().GetOcrString().GetFirstString().GetCStr(),            std::regex("\""), "\\\"");       printf(",\"%s\": \"%s\"",            f_it.GetKey(),           escaped_value.c_str());     }     printf("}\n");   } }

3. Переименуем получившийся исходник в docengine_cli.cpp и перенесем его в директорию рядом с динамической библиотекой libdocengine.dylib (в моем случае — в директорию /bin SDK-пакета), после чего скомпилируем с rpath-привязкой так, чтобы он искал библиотеку рядом в исполняемым файлом:

$ clang++ docengine_cli.cpp -O2 -I ../include -L. -l docengine -o docengine_cli -Wl,-rpath,"@executable_path"

Проверяем (в вывод программы добавлены переводы строк для читабельности):

$ ./docengine_cli ../testdata/rus.payment_order_sample.png ../data-zip/bundle_docengine_photo.se "rus.payment_order*" {"DOCTYPE": "rus.payment_order.type1","amount": "20 003 000-00",  "amount_words": "ДВАДЦАТЬ МИЛЛИОНОВ ТРИ ТЫСЯЧИ РУБЛЕЙ 00 КОПЕЕК",  "beneficiary": "ООО \"МЕЧТА\"","beneficiary_account": "11223344556677889900",  "beneficiary_bank": "МЕЖДУНАРОДНЫЙ ЗАПАДНЫЙ БАНК",  "beneficiary_bank_invoice": "33344455566677788899",  "bik_beneficiary": "987654321","bik_payer": "012345678",  "budget_classification_code": "","code1": "0401060","code_payment": "",  "date": "05.11.2020","date_document_payment_basis": "",  "debiting_date": "05.11.2020","inn_beneficiary": "1111111111",  "inn_payer": "1234567890","invoice_number": "98765432109876543210",  "kpp_beneficiary": "222222222","kpp_payer": "125125125",  "number_document_basis_payment": "","number_payment_order": "345",  "oktmo_code": "","payer": "ИП \"ДОБРОЕ УТРО\"",  "payer_bank": "ПУШКИНСКОЕ ОТДЕЛЕНИЕ БАНК \"ЗДОРОВЬЕ\"",  "payer_bank_invoice": "12345678901234567890","payment_code": "0",  "payment_reason_code": "","payment_type": "","place_payment": "8",  "purpose_payment": "ОПЛАТА ПО ДОГОВОРУ №23456 ЗА ВЫПОЛНЕНИЕ СТРОИТЕЛЬНЫХ И ФУНКЦИОНАЛЬНЫХ РАБОТ ПО ИССЛЕДОВАНИЮ ОРГАНИЗМА. НДС НЕ ОБЛАГАЕТСЯ",  "purpose_payment_1": "","receipt_date": "05.11.2020","tax_period": "",  "type_payment": "","wage_type": "01"}

То, что надо! Теперь переходим к шаблонизатору.

Шаблонизатор

Что хотим? Хотим простое GUI-приложение, которое бы умело загружать шаблонные документы в формате DOCX, в стратегических местах которых проставлены теги вида ${very_important_info}, загружать изображения нужных документов, и сохранять документ с заполненными данными.

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

Пусть из платежного поручения мы хотим извлекать название плательщика и его банка, реквизиты получателя, сумму прописью и назначение платежа. Из 2-НДФЛ извлекаем ФИО, дату рождения (справка о доходах физического лица, формально говоря, уже не называется 2-НДФЛ, но изжить такой прижившийся термин, думаю, будет непросто), и, наконец, из справки о социальном номере Армении извлекаем ФИО на армянском и, собственно, номер. Для целей демонстрации возможностей шаблонизатора вполне хватит. Конфигурационный файл (config.json) получился такой:

{   "executable": "docengine_cli",   "bundle": "bundle_docengine_photo.se",   "sessions": {     "rus_payment_order": {       "documents_mask": "rus.payment_order*",       "text": "payment order"     },     "arm_social_card": {       "documents_mask": "arm.ref_public*",       "text": "social card"     },     "rus_2ndfl": {       "documents_mask": "rus.2ndfl*",       "text": "income form"     }   },   "tags": {     "rus_payment_order:payer": "payer_name",     "rus_payment_order:payer_bank": "payer_bank_name",     "rus_payment_order:beneficiary": "beneficiary_name",     "rus_payment_order:beneficiary_account": "beneficiary_account",     "rus_payment_order:beneficiary_bank": "beneficiary_bank_name",     "rus_payment_order:bik_beneficiary": "beneficiary_bik",     "rus_payment_order:kpp_beneficiary": "beneficiary_kpp",     "rus_payment_order:amount_words": "payment_amount",     "rus_payment_order:purpose_payment": "payment_purpose",     "rus_2ndfl:surname": "surname",     "rus_2ndfl:name": "name",     "rus_2ndfl:patronymic": "patronymic",     "rus_2ndfl:birth_date": "birth_date",     "arm_social_card:name_patronymic_surname": "arm_fio",     "arm_social_card:public_service_number": "arm_number"   } }

Конфигурационный файл поместим в директорию resources, вместе со всем необходимым для запуска распознавания: конфигурационным бандлом bundle_docengine_photo.se, исполняемым файлом docengine_cli и библиотекой libdocengine.dylib.

В качестве самого шаблонизатора напишем простенькое GUI-приложение на wxPython. Не имеет смысла углубляться в детали, ограничусь лишь тем, что у меня ушло на все про все около двух часов (без опыта работы с wx) и 292 строчки кода. Разберем лишь процедуры распознавания изображения и заполнения шаблона.

В GUI-приложении распознавание изображения инициируется нажатием на кнопку, которая соответствует той или иной сессии распознавания, прописанной в config.json. Предлагаем пользователю выбрать файл с изображением документа, после чего запускаем docengine_cli при помощи модуля subprocess и парсим JSON, который получаем на выходе. После этого, согласно прописанным тегам в config.json обновляем словарь со значениями тегов:

def loadImage(self, event):   '''     Загружает изображение, распознает документ, обновляет словарь тегов   '''   button_name = event.GetEventObject().GetName() # соответствует ключу в словаре “sessions” конфигурационного файла config.json   self.tlog.AppendText('Loading image of %s...\n' % self.config['sessions'][button_name]['text'])    with wx.FileDialog(self, 'Open %s image file' % self.config['sessions'][button_name]['text'], \                      wildcard="PNG, JPG or TIF image (*.png;*.jpg;*.jpeg;*.tif;*.tiff)|*.png;*.jpg;*.jpeg;*.tif;*.tiff", \                      style=wx.FD_OPEN | wx.FD_FILE_MUST_EXIST) as fileDialog:     if fileDialog.ShowModal() == wx.ID_CANCEL:       return      pathname = fileDialog.GetPath()     try:       self.tlog.AppendText('Recognizing %s...\n' % pathname)       # запускаем docengine_cli       output = subprocess.run([         os.path.join(self.resources_path, self.config['executable']), # путь к исполняемому файлу docengine_cli         pathname, # путь к изображению         os.path.join(self.resources_path, self.config['bundle']), # путь к конфигурационному бандлу Smart Document Engine         Self.config['sessions'][button_name]['documents_mask'] # маска типа документа       ], capture_output = True)        # парсим вывод docengine_cli       output_json = None       try:         output_json = json.loads(output.stdout)       except Exception:         pass        if output_json is None:         self.tlog.AppendText('Failed to retrieve any data.\n')       else:         # обновляем словарь тегов         any_fields_extracted = False         for tag in self.config['tags'].keys():           if tag.split(':')[0] != button_name:             continue           prop_name = tag.split(':')[-1]           if prop_name not in output_json.keys():             continue           prop_value = output_json[prop_name]           self.keyval[self.config['tags'][tag]] = prop_value           self.tlog.AppendText('Extracted %s: %s\n' % (self.config['tags'][tag], prop_value))           any_fields_extracted = True          if not any_fields_extracted:           self.tlog.AppendText('No fields extracted.\n')      except Exception as e:       self.tlog.AppendText('Cannot process file %s: %s\n' % (pathname, str(e)))

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

def applyTagsToParagraph(self, paragraph):   '''     Применяет словарь тегов self.keyval к одному параграфу DOCX-документа, сохраняя формат куска, содержащего символ “$”.   '''   for i in range(len(paragraph.runs)):     while '$' in paragraph.runs[i].text:       end_index = -1       found_key = None       composite_text = ''       for j in range(i, len(paragraph.runs)):         composite_text += paragraph.runs[j].text         for key in self.keyval.keys():           if '${%s}' % key in composite_text:             found_key = key             end_index = j             break         if found_key is not None:           break       if found_key is not None:         paragraph.runs[i].text = composite_text.replace('${%s}' % found_key, self.keyval[found_key])         for k in range(i + 1, end_index + 1):           paragraph.runs[k].clear()       else:         break  def saveDocument(self, event):   '''     Загружает шаблон документа из self.template_path, применяет словарь тегов self.keyval к документу и предлагает пользователю сохранить получившийся документ.   '''   if len(self.keyval) == 0:     self.tlog.AppendText('Nothing to apply.\n')     return    self.tlog.AppendText('Applying values to template file %s:\n' % self.template_path)   for k, v in self.keyval.items():     self.tlog.AppendText('  %s: %s\n' % (k, v))    document = docx.Document(self.template_path)    # применяем к параграфам документа   for paragraph in document.paragraphs:     self.applyTagsToParagraph(paragraph)   # применяем к таблицам документа   for table in document.tables:     for row in table.rows:       for cell in row.cells:         for paragraph in cell.paragraphs:           self.applyTagsToParagraph(paragraph)    with wx.FileDialog(self, "Save DOCX file", wildcard="DOCX files (*.docx)|*.docx", \                      style=wx.FD_SAVE | wx.FD_OVERWRITE_PROMPT) as fileDialog:      if fileDialog.ShowModal() == wx.ID_CANCEL:       return      pathname = fileDialog.GetPath()     # на всякий случай добавляем расширение docx и безопасно сохраняем файл     if not pathname.lower().endswith('.docx'):       new_pathname = pathname + '.docx'       while os.path.exists(new_pathname):         new_pathname = new_pathname[:-5] + '-copy.docx'       pathname = new_pathname     try:       document.save(pathname)       self.tlog.AppendText('Saved to %s\n' % pathname)     except IOError:       self.tlog.AppendText('Cannot save to file %s\n' % pathname)

Шаблонизатор готов! Для того, чтобы запускать его как любое другое приложение, можно применить удобный инструмент pyinstaller, он позволяет создавать готовое приложение для целевой операционки, упаковать внутрь директорию resources и подложить иконку:

$ pyinstaller -w docengine_templater.py --name="Docengine Templater" --add-data resources:resources -i docengine.icns

Тестим!

Для теста шаблонизатора создадим простой DOCX-файл, использующий все теги, которые мы ранее добавляли в config.json:

Окно шаблонизатора после загрузки шаблона и распознавания трех изображений:

Сохраненный документ:

На этом, пожалуй все! Код шаблонизатора (и модифицированного семпл-приложения docengine_cli.cpp) вы можете посмотреть здесь.

Если вам интересен продукт Smart Document Engine, вы можете узнать про него больше на сайте нашей компании, или обратиться там же к нашим специалистам за подробностями.

Спасибо за внимание!


ссылка на оригинал статьи https://habr.com/ru/company/smartengines/blog/672896/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *