Время прочтения: 8 мин.

Нужно найти в docx-файле определенный фрагмент и оставить к нему комментарий? bayoo-docx (форк python-docx) умеет это! В конце в виде бонуса расскажу, как определить номер страницы. 😊

Долгое время в библиотеке python-docx отсутствовала возможность добавления комментариев к word-файлам «из коробки». Созданное еще в 2014 году обсуждение в репозитории python-docx о том, как добавлять комментарии, было довольно активным, но не было найдено решений без прямого вмешательства в xml-разметку. Однако в 2020 году появился форк от python-docx – bayoo-docx, позволяющий добавлять комментарии быстро и легко.

Начнем с установки bayoo-docx:

!pip install bayoo-docx

Для сравнения строк будет использоваться thefuzz, о нем можно почитать здесь. Если кратко, то он осуществляет сравнение двух строк и возвращает процент похожести, используя расстояние Левенштейна. Устанавливается следующей командой:

!pip install thefuzz[speedup]

Импортируем необходимые модули:

from docx import Document 
from thefuzz import fuzz
import ctypes

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

Желательно, чтобы был пример, с которым можно поиграться (по себе знаю, как тяжело бывает разобраться без реальных примеров).

Давайте создадим такой документ для теста:

# создание пустого документа
doc = Document()

Объект Document представляет собой весь документ – его структура:

  • Список объектов paragraph – абзацы документа
    • Список объектов run – фрагменты текста с различными стилями форматирования (курсив, цвет шрифта и т.п.)
  • Список объектов table – таблицы документа
    • Список объектов row – строки таблицы
    • Список объектов cell – ячейки в строке
      • Список объектов cell.paragraphs содержит все абзацы в ячейке
    • Список объектов column – столбцы таблицы
      • Список объектов cell – ячейки в столбце
  • Список объектов InlineShape – иллюстрации документа
# добавляем абзацы
doc.add_paragraph('Первый абзац, первая страница')
doc.add_paragraph('Второй абзац, первая страница')
doc.add_paragraph('Третий абзац, первая страница')

# добавляем разрыв страницы
doc.add_page_break()

# добавляем абзацы на второй странице
doc.add_paragraph('Первый абзац, вторая страница')
doc.add_paragraph('Второй абзац, вторая страница')
doc.add_paragraph('Третий абзац, вторая страница')

# данные таблицы без названий колонок
items = (
    (1, 'первая строка', 'первая строка'),
    (2, 'вторая строка', 'вторая строка'),
    (3, 'третья строка', 'третья строка'),
)

# добавляем таблицу с одной строкой 
# для заполнения названий колонок
table = doc.add_table(1, len(items[0]))

# определяем стиль таблицы
table.style = 'Light Shading Accent 1'

# Получаем строку с колонками из добавленной таблицы
head_cells = table.rows[0].cells

# добавляем названия колонок
for i, item in enumerate(['первая колонка', 'вторая колонка', 'третья колонка']):
    p = head_cells[i].paragraphs[0]
    # название колонки
    p.add_run(item).bold = True
    # выравниваем посередине
    p.alignment = WD_ALIGN_PARAGRAPH.CENTER
    
# добавляем данные к существующей таблице
for row in items:
    # добавляем строку с ячейками к объекту таблицы
    cells = table.add_row().cells
    for i, item in enumerate(row):
        # вставляем данные в ячейки
        cells[i].text = str(item)
        
# сохраняем тестовый файл с которым будем работать       
doc.save('test.docx')

Вид созданного документа:

Первая страница



Вторая страница

Напишем функцию, которая будет проходить по списку document.elements (содержит список всех объектов документа) и добавлять в него все объекты paragraph (абзацы), а из таблиц извлекать все объекты paragraph, которые лежат в ячейках.

Объекты тестового файла:

# содержание списка elements
for element in doc.elements:
    print(element)

<docx.text.paragraph.Paragraph object at 0x0000026F87D13580>
<docx.text.paragraph.Paragraph object at 0x0000026F87D13550>
<docx.text.paragraph.Paragraph object at 0x0000026F87D13BB0>
<docx.text.paragraph.Paragraph object at 0x0000026F87D13790>
<docx.text.paragraph.Paragraph object at 0x0000026F87D138B0>
<docx.text.paragraph.Paragraph object at 0x0000026F87D13400>
<docx.text.paragraph.Paragraph object at 0x0000026F87D13AF0>
<docx.table.Table object at 0x0000026F87D13B50>
<docx.section.Section object at 0x0000026F87D13D30>

Список списков с абзацами нужен из-за того, что добавление комментариев к абзацу в тексте и в таблице осуществляется с небольшими различиями и, конечно, чтобы сэкономить строчки кода. Ячейки в таблице устроены таким образом, что могут содержать короткую строку, но из нескольких абзацев, и в каждом абзаце содержатся прогоны (runs), которые нужно склеить и получить полный текст ячейки для осуществления поиска.

Функция сбора абзацев из файла:

def filter_element(document):
    """
    This function take all paragraphs in file.

    :param document: object of document
    :return: list of paragraphs in file.docx - document
    """
    res = []
    for element in document.elements:
        if 'paragraph' in str(element): 
            res.append(element)
        elif 'table' in str(element):
            for row in element.rows:
                for cell in row.cells:
                    res.append(cell.paragraphs)
    return res 

Функция определения полного имени пользователя:

def get_display_name():
    """
    This function return full name of user.
    out:
        string: full name of user
    """
    get_user_name_ex = ctypes.windll.secur32.GetUserNameExW
    name_display = 3
    size = ctypes.pointer(ctypes.c_ulong(0))
    get_user_name_ex(name_display, None, size)
    name_buffer = ctypes.create_unicode_buffer(size.contents.value)
    get_user_name_ex(name_display, name_buffer, size)
    return name_buffer.value

И, наконец, основная функция поиска строки и добавления комментария:

def make_comment(text:str, paragraphs:list, user:str):
    """
    This function adds comments in docx files.
    :param text: the line we are looking for
    :param paragraphs: list of paragraphs to search for a string
    :param user: full name of user
    """
    for paragraph in paragraphs:
        if type(paragraph) == list: 
            text_in_table = [p.text for p in paragraph]
            text_in_table = ''.join(text_in_table) 
            if len(text_in_table) >= len(text)-5: 
                res = fuzz.partial_ratio(text.lower(), text_in_table.lower())
                if res >= 97: 
                    p = paragraph[-1]
                    run = p.add_run()
                    run.add_comment('Строчка которую искали', author=user) 
        else: 
            if len(paragraph.text) >= len(text):
                res = fuzz.partial_ratio(text.lower(), paragraph.text.lower())
                if res >= 97:
                    paragraph.add_comment('Строчка которую искали', author=user)

Загрузим объект тестового файла в переменную document:

document = Document('test.docx')

Строка для поиска:

text = 'Первый абзац, первая страницы'

Список абзацев для поиска:

paragraphs = filter_element(document)

Сохраняем полное имя пользователя в переменную name_of_user:

name_of_user = get_display_name()

Вызываем функцию и передаем аргументы (искомая строка, список абзацев, полное имя пользователя):

make_comment(text, paragraphs, name_of_user)

Сохраняем изменения в новый файл:

document.save('test с комментарием.docx')

Появилась копия файла с комментарием:

Давайте попробуем найти строчку в таблице:

# объект документа
document = Document('test.docx')
# строчка которую ищем
text = "Первая строка"
# список абзацев для поиска
paragraphs = filter_element(document)
# полное имя пользователя
name_of_user = get_display_name()
# вызываем функцию добавления комментарий
make_comment(text, paragraphs, name_of_user)
# сохраняем изменения в файл .docx
document.save('test с комментарием.docx')

Результат:

Комментарий будет сделан к каждому совпадению.

Обещанный бонус

Не все функции можно реализовать «из коробки», например, определить номер страницы строки, которую я нашёл, ☹ так легко не получится. В таких случаях на помощь приходят «костыли» 😉.

Разрывы страницы бывают двух видов:

  1. hard breaks – разрывы, вставленные с помощью Ctrl + Enter.
  2. soft breaks – разрывы, вставленные, когда автор печатал текст, и произошел автоматический переход на новую страницу.

Обнаружить эти два вида разрыва страницы можно в xml-разметке объектов run (run._element.xml), которые есть у каждого объекта paragraph.

Напишем функцию определения номера страницы для искомой строки:

def number_page(text:str, paragraphs:list):
    """
    This funcion find number page.

    :param text: string what we find
    :param paragraphs: list of paragraphs
    :return: pages
    """
    pages = []
    number_page = 1
    for paragraph in paragraphs:
        if type(paragraph) == list: 
            text_in_table = [p.text for p in paragraph]
            text_in_table = ''.join(text_in_table)
            for p in paragraph:
                for run in p.runs: 
                    if 'lastRenderedPageBreak' in run._element.xml:
                        number_page += 1 
                    elif 'w:br' in run._element.xml and 'type="page"' in run._element.xml:
                        number_page += 1
            if len(text_in_table) >= len(text)-10:
                res = fuzz.partial_ratio(text.lower(), text_in_table.lower())
                if res >= 97: 
                    pages.append(number_page)
        else: 
            for run in paragraph.runs:
                if 'lastRenderedPageBreak' in run._element.xml:
                    number_page += 1
                elif 'w:br' in run._element.xml and 'type="page"' in run._element.xml:
                    number_page += 1
            if len(paragraph.text) >= len(text):
                res = fuzz.partial_ratio(text.lower(), paragraph.text.lower())
                if res >= 97:
                    pages.append(number_page)
    return ', '.join(map(str, pages))

Вот здесь XML-разметка объекта run проверяется на наличие тегов, указывающих на наличие разрыва страницы:

if 'lastRenderedPageBreak' in run._element.xml:
    number_page += 1 
elif 'w:br' in run._element.xml and 'type="page"' in run._element.xml:
    number_page += 1

Как и в прошлый раз, подготавливаем данные для функции:

# создаем объект document
document = Document('test.docx')
# снова собираем в список абзацы документа
paragraphs = filter_element(document)
# строка номер которой хотим найти
text = "Первая строка"

Для нахождения номера страницы нужно передать в функцию number_page первым аргументом строку, которую искали, вторым – список paragraphs.

print(number_page(text, paragraphs))

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

Jupyter notebook с подробными комментариями к коду доступен по ссылке.

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