Мы в 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, вы можете узнать про него больше на сайте нашей компании, или обратиться там же к нашим специалистам за подробностями.
Спасибо за внимание!