Как стать автором
Обновить
91.74
Smart Engines
Обработка изображений, распознавание в видеопотоке

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

Время на прочтение10 мин
Количество просмотров4.9K

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

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

Теги:
Хабы:
Всего голосов 8: ↑6 и ↓2+4
Комментарии15

Публикации

Информация

Сайт
smartengines.ru
Дата регистрации
Дата основания
Численность
51–100 человек
Местоположение
Россия