Введение

Доброго времени суток.

Сразу скажу, что я не разработчик. Лишь системный-аналитик в абстрактной международной компании. Так что, прошу за код не бить палками.

Цель статьи: если кто-то будет гуглить про встраивание MergeField в docx с помощью Python, то это заняло чуть меньше времени чем у меня.

Предыстория. Как обычно бывает, бизнес что-то придумал, собрали встречу. У юристов была/есть проблема, что пункты в различных договорах одинаковые и если надо внести изменения в одном таком пункте, то надо пересмотреть абсолютно все договора, где он участвует. Юристы показали идею, что есть excel документ, где указаны пункты договора. Эти пункты можно промаркировать, что они относятся к одному или другому документу. Потом должен появится какой-то VBA скрипт (или что-то похожее), который должен уже собрать полноценные документы по маркировкам. Формат выходного файла - docx. Стандартный шаблонный документ.

Мне задача показалась интересной, тем более уже был опыт работы на Python с библиотекой pandas для чтения данных из excel. Вторым пунктом для выбора технологии послужило, что я VBA знаю и понимаю гораздо хуже чем Python.

Так это превратилось в pet-проект.

Собственно, можно сказать, здесь началось самое интересное. Чтение файла и создание docx не вызвало особо проблем. Но, как все понимают, в договорах довольно много переменных, взять хотя бы данные клиента, которые должны быть вставлены блок подписей документа.

Про MergeField и Python

Переменные в docx реализованы через MergeField.

Соответственно встает первый вопрос, который вызвал проблемы - как с помощью библиотеки python-docx вставить такие поля?

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

Обычно, на момент гугления я натыкался на ниже описанный пример создаваемого XML.

Пример взят из: https://stackoverflow.com/questions/37518114/fill-mergefield-using-openxml

<w:r>
  <w:fldChar w:fldCharType="begin" />
</w:r>
<w:r>
  <w:instrText xml:space="preserve"> MERGEFIELD  TestFoo  \* MERGEFORMAT </w:instrText>
</w:r>
<w:r>
  <w:fldChar w:fldCharType="separate" />
</w:r>
<w:r w:rsidR="00FA6E12">
  <w:rPr>
    <w:noProof />
  </w:rPr>
  <w:t>«TestFoo»</w:t>
</w:r>
<w:r>
  <w:rPr>
    <w:noProof />
  </w:rPr>
  <w:fldChar w:fldCharType="end" />
</w:r>

Соответственно тоже сделал по аналогии.

Получилось, что-то похожее, как на странице - https://github.com/python-openxml/python-docx/issues/262

def _create_field(self, paragraph, value, text="F9"):
        r = paragraph._p.add_r()
        sfldChar = OxmlElement('w:fldChar')
        sfldChar.set(qn('w:fldCharType'), "begin")
        r.append(sfldChar)
        r = paragraph._p.add_r()
        instrText = OxmlElement('w:instrText')
        instrText.text = value
        r.append(instrText)
        r = paragraph._p.add_r()
        fldChar = OxmlElement('w:fldChar')
        fldChar.set(qn('w:fldCharType'), "separate")
        r.append(fldChar)
        paragraph.add_run(text)
        r = paragraph._p.add_r()
        efldChar = OxmlElement('w:fldChar')
        efldChar.set(qn('w:fldCharType'), "end")
        r.append(efldChar)
        return DocxField(sfldChar, efldChar)

Но мне показалось, что данное решение не очень красивое и не все учитывает, что есть в Word.

Реализация

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

from docx import Document
from docx.oxml import OxmlElement
from docx.oxml.ns import qn
from docx.text.run import Run


def add_mergefield(field_name: str, **kwargs) -> Run:
    '''
    Add mergefield in docx.text.run

    Usage: add_mergefield(
        'str',
        before='text',
        after='text'
    )

    :param field_name: the name of new field
    :param kwargs:
        run = run where you need to set mergfiled;
        format = one of |Upper|, |Lower|, |FirstCap|, |TitleCase|;
        before = text before field;
        after = text after field;
        mapped = True - mapped field;
        vertical = True - vertical format
    :return: Run with added mergfiled
    '''
    if 'run' in kwargs:
        run = kwargs['run']
    else:
        run = Document().add_paragraph('').add_run()._r

    field_option = ''
    field = ''

    if field_name[0:1] == '«' and field_name[-1:] == '»':
        field_option = f' MERGEFIELD ' + field_name[1:-1]
        field = field_name
    else:
        field_option = f' MERGEFIELD ' + field_name
        field = '«' + field_name + '»'

    ordered_kwargs = {}
    if 'format' in kwargs: ordered_kwargs['format'] = kwargs['format']
    if 'before' in kwargs: ordered_kwargs['before'] = kwargs['before']
    if 'after' in kwargs: ordered_kwargs['after'] = kwargs['after']
    if 'mapped' in kwargs: ordered_kwargs['mapped'] = kwargs['mapped']
    if 'vertical' in kwargs: ordered_kwargs['vertical'] = kwargs['vertical']

    for key, value in ordered_kwargs.items():
        if key == 'format':
            if value == 'Upper':
                field_option += f' \* Upper'
            if value == 'Lower':
                field_option += f' \* Lower'
            if value == 'FirstCap':
                field_option += f' \* FirstCap'
            if value == 'TitleCase':
                field_option += f' \* Caps'
        if key == 'before':
            field_option += f' \\b ' + value
        if key == 'after':
            field_option += f' \\f ' + value
        if key == 'mapped' and value == True:
            field_option += f' \\m'
        if key == 'vertical' and value == True:
            field_option += f' \\v'

    field_option += f' \* MERGEFORMAT '

    ordered_kwargs = {}
    if 'before' in kwargs: ordered_kwargs['before'] = kwargs['before']
    if 'after' in kwargs: ordered_kwargs['after'] = kwargs['after']
    if 'format' in kwargs: ordered_kwargs['format'] = kwargs['format']

    for key, value in ordered_kwargs.items():
        if key == 'before':
            field = value + ' ' + field
        if key == 'after':
            field = field + ' ' + value
        if key == 'format':
            if value == 'Upper':
                field = field.upper()
            if value == 'Lower':
                field = field.lower()
            if value == 'FirstCap':
                field = field.capitalize()
            if value == 'TitleCase':
                old_field = field
                field = ''
                for str in old_field.split():
                    field += str.capitalize() + ' '
                field = field.strip()

    # <w:fldSimple w:instr=" MERGEFIELD $offerNumber \* Upper \b asd \* MERGEFORMAT ">
    # <w:r>
    # <w:t>ASD «$OFFERNUMBER»</w:t>
    # </w:r>
    # </w:fldSimple>
    fld = create_element('w:fldSimple', run)
    create_attribute(fld, 'w:instr', field_option)
    obj = create_element('w:r', fld)
    obj = create_element('w:t', obj)
    obj.text = field

    return run

def create_element(name:str, parent=None):
    '''
    Create new object in XML tree.

    :param name: type name of new object
    :param parent: obj created by OxmlElement()
    :return: created Object OR created child Object
    '''
    sub_obj = OxmlElement(name)
    if parent is not None:
        try:
            parent.append(sub_obj)
            return sub_obj
        except Exception:
            print('oops')
    else:
        return sub_obj


def create_attribute(element, name, value):
    element.set(qn(name), value)

Вторым вопросом с которым я столкнулся, была необходимость вставлять форматированный текст из excel. Здесь я решил задачу с помощью markdown и в частности библиотеки mistletoe. Данная библиотека возвращает массив с указанием стилей.

И вишенкой на торте стала необходимость передать все это добро обычному пользователю. Здесь, решение тоже довольно стандартное. Использовалась библиотека auto-py-to-exe.

Конечный результат можно посмотреть здесь.

Заключение

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

Надеюсь, что это кому-то помогло и что у кого-то другого ушло чуть меньше времени на гугление информации на тему "как создать MergeField в docx". 

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


  1. kAIST
    16.01.2022 00:29

    А чем не угодила библиотека docxtpl? Берём договор, делаем из него шаблон с помощью простого синтаксиса, и прогоняем с нужными данными. Или я не верно понял задачу?


    1. funGhost Автор
      17.01.2022 09:13

      Спасибо за подсказку. На такую библиотеку даже не натыкался. Почитал про нее и не до конца понял, что она делает. Вроде пишут, что позволяет редактировать документ и заполнять данными в переменные. У меня цель была, именно создание шаблонов docx с нуля.

      Своим скриптом, как раз, создаю те самые шаблоны, которые надо заполнить. А заполнял, ради эксперимента, с помощью библиотеки docx-mailmerge.


    1. funGhost Автор
      17.01.2022 09:28

      Дополню. Чуть ниже, hello-kokos, приводит пример сервиса. Как я понял, там используется именно эта технология. Пошел именно представленным путем из-за того, что я подстраивался под уже существующие процессы. Это выглядит следующим образом: создается шаблон юристами, системный-аналитик вставляет переменные в шаблон, разработчик прикладывает шаблон в поставку, система на Java берет шаблон и заполняет в необходимые поля данные.

      Т.е. предлагаемый шаблонизатор вполне бы работал в системе на Python, но здесь другой кейс.


  1. StVorpensi
    16.01.2022 11:18

    XSLT как я понял уже вообще не модно


  1. FlyingDutchman2
    16.01.2022 13:03

    У юристов была/есть проблема, что пункты в различных договорах одинаковые и если надо внести изменения в одном таком пункте, то надо пересмотреть абсолютно все договора, где он участвует.

    Решение, которое сразу приходит в голову - использовать TeX (а точнее макропакет LaTeX) для написания договоров и оформлять пункты договоров отдельными макросами. После чего очень легко поменять какой-нибудь пункт договора.

    Но, к сожалению, TeX не особо популярен, особенно среди юристов...


  1. hello-kokos
    17.01.2022 09:21

    Специально для таких вещей создал https://printdoc.io