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

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

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

> pip install python-docx

После успешной установки библиотеки, её нужно импортировать в Python. Обратите внимание, что несмотря на то, что для установки использовалось название python-docx, при импорте следует называть библиотеку docx:

import docx

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

import os

paths = []
folder = os.getcwd()
for root, dirs, files in os.walk(folder):
    for file in files:
        if file.endswith('docx') and not file.startswith('~'):
            paths.append(os.path.join(root, file))

Мы прошли по всем директориям и занесли в список paths все файлы с расширением .docx. Файлы, начинавшиеся с тильды, игнорировались (эти временные файлы возникают лишь тогда, когда в Windows открыт какой-либо из документов). Теперь, когда у нас уже есть список всех документов, можно начинать с ними работать:

for path in paths:
    doc = docx.Document(path)

В блоке выше на каждом шаге цикла в переменную doc записывается экземпляр, представляющий собой весь документ.  Мы можем посмотреть основные свойства такого документа:

properties = doc.core_properties
print('Автор документа:', properties.author)
print('Автор последней правки:', properties.last_modified_by)
print('Дата создания документа:', properties.created)
print('Дата последней правки:', properties.modified)
print('Дата последней печати:', properties.last_printed)
print('Количество сохранений:', properties.revision)

Из основных свойств можно получить автора документа, основные даты, количество сохранений документа и пр. Обратите внимание, что даты и время будут указаны в часовом поясе UTC+0.

Теперь поговорим о том, как можно проанализировать содержимое документа. Файлы с расширением docx обладают развитой внутренней структурой, которая в библиотеке docx представлена следующими объектами:

  • Объект Document, представляющий собой весь документ
    • Список объектов Paragraph – абзацы документа
      • Список объектов Run – фрагменты текста с различными стилями форматирования (курсив, цвет шрифта и т.п.)
    • Список объектов Table – таблицы документа
      • Список объектов Row – строки таблицы
        • Список объектов Cell – ячейки в строке
      • Список объектов Column – столбцы таблицы
        • Список объектов Cell – ячейки в столбце
    • Список объектов InlineShape – иллюстрации документа

Работа с текстом документа

Для начала давайте разберёмся, как работать с текстом документа. В библиотеке docx это возможно через обращение к абзацам документа. Можно получить как сам текст абзаца, так и его характеристики: тип выравнивания, величину отступов и интервалов, положение на странице.

Очень часто стоит задача получить весь текст из документа для дальнейшей обработки. Чтобы это сделать, достаточно лишь перебрать все абзацы документа:

text = []
for paragraph in doc.paragraphs:
    text.append(paragraph.text)
print('\n'.join(text))

Как мы видим, для получения текста абзаца нужно просто обратиться к объекту paragraph.text. Но что же делать, если нужно извлечь только абзацы с определёнными характеристиками и далее работать именно с ними? Рассмотрим основные характеристики абзацев, которые можно проанализировать.

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

for paragraph in doc.paragraphs:
    print('Выравнивание абзаца:', paragraph.alignment)

Значения alignment будут соответствовать одному из основных стилей выравнивания: LEFT (0), CENTER (1), RIGHT (2) или JUSTIFY (3). Однако если пользователь не установил стиль выравнивания, значение параметра alignment будет None.

Кроме того, можно получить и значения отступов у абзацев документа:

for paragraph in doc.paragraphs:
    formatting = paragraph.paragraph_format
    print('Отступ перед абзацем:', formatting.space_before)
    print('Отступ после абзаца:', formatting.space_after)
    print('Отступ слева:', formatting.left_indent)
    print('Отступ справа:', formatting.right_indent)
    print('Отступ первой строки абзаца:', formatting.first_line_indent)

Как и в предыдущем примере, если отступы не были установлены, значения параметров будут None. В остальных случаях они будут представлены в виде целого числа в формате EMU (английские метрические единицы). Этот формат позволяет конвертировать число как в метрическую, так и в английскую систему мер. Привести полученные числа в привычный формат довольно просто, достаточно просто добавить нужные единицы исчисления после параметра (например, formatting.space_before.cm или formatting.space_before.pt). Главное помнить, что такое преобразование нельзя применять к значениям None.

Наконец, можно посмотреть на положение абзаца на странице. В меню Абзац… на вкладке Положение на странице находятся четыре параметра, значения которых также можно посмотреть при помощи библиотеки docx:

for paragraph in doc.paragraphs:
    formatting = paragraph.paragraph_format
    print('Не отрывать от следующего абзаца:', formatting.keep_with_next)
    print('Не разрывать абзац:', formatting.keep_together)
    print('Абзац с новой страницы:', formatting.page_break_before)
    print('Запрет висячих строк:', formatting.widow_control)

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

Мы рассмотрели основные способы, которыми можно проанализировать абзац в документе. Но бывают ситуации, когда мы точно знаем, что информация, которую нужно извлечь, написана курсивом или выделена определённым цветом. Как быть в таком случае?

Можно получить список фрагментов с различными стилями форматирования (список объектов Run). Попробуем, к примеру, извлечь все фрагменты, написанные курсивом:

for paragraph in doc.paragraphs:
    for run in paragraph.runs:
        if run.italic:
            print(run.text)

Очень просто, не так ли? Посмотрим, какие ещё стили форматирования можно извлечь:

for paragraph in doc.paragraphs:
    for run in paragraph.runs:
        print('Полужирный текст:', run.bold)
        print('Подчёркнутый текст:', run.underline)
        print('Зачёркнутый текст:', run.strike)
        print('Название шрифта:', run.font.name)
        print('Цвет текста, RGB:', run.font.color.rgb)
        print('Цвет заливки текста:', run.font.highlight_color)

Если пользователь не менял стиль форматирования (отсутствует подчёркивание, используется стандартный шрифт и т.п.), параметры будут иметь значение None. Но если стиль определённого параметра изменялся, то:

  • параметры italic, bold, underline, strike будут иметь значение True;
  • параметр font.name – наименование шрифта;
  • параметр font.color.rgb – код цвета текста в RGB;
  • параметр font.highlight_color – наименование цвета заливки текста.

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

Абзацы и их фрагменты могут быть оформлены в определённом стиле, соответствующем стилям Word (например, Normal, Heading 1, Intense Quote). Чем это может быть полезно? К примеру, обращение к стилям абзаца может пригодиться при выделении нумерованных или маркированных списков. Каждый элемент таких списков считается отдельным абзацев, однако каждому из них приписан особый стиль – List Paragraph. С помощью кода ниже можно извлечь только элементы списков:

for paragraph in doc.paragraphs:
    if paragraph.style.name == 'List Paragraph':
        print(paragraph.text)

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

for path in paths:
    doc = docx.Document(path)
    product_names = []
    for paragraph in doc.paragraphs:
        formatting = paragraph.paragraph_format
        if formatting.page_break_before and paragraph.alignment == 3:
            product_name, is_sequential = '', False
            for run in paragraph.runs:
                if run.bold and run.font.name == 'Arial Narrow':
                    is_sequential = True
                    product_name += run.text
                elif is_sequential == True:
                    product_names.append(product_name)
                    product_name, is_sequential = '', False

В блоке кода выше последовательно обрабатываются все файлы из списка paths, преобразовываемые в ходе обработки в объект Document. В каждом документе происходит перебор абзацев и выполняются проверки: абзац должен начинаться с новой страницы и быть выровненным по ширине. Если проверки прошли успешно, внутри абзаца происходит уже перебор фрагментов с различными типами форматированием и проверки на начертание и шрифт.

Обратим внимание на переменную is_sequential, которая помогает определить, идут ли фрагменты, прошедшие проверку, друг за другом. Фрагменты с символами разных типов (буквы и числа, кириллица и латиница) разбиваются на несколько, но поскольку в названии продукта одновременно могут встретиться символы всех типов, все последовательно идущие фрагменты соединяются в один. Он и заносится в результирующий список product_names.

Работа с таблицами

Мы рассмотрели способы, которыми можно обрабатывать текст в документах, а теперь давайте перейдём к обработке таблиц. Любую таблицу можно перебирать как по строкам, так и по столбцам. Посмотрим, как можно построчно получить текст каждой ячейки в таблице:

for table in doc.tables:
    for row in table.rows:
        for cell in row.cells:
            print(cell.text)

Если же во второй строке заменить rows на columns, то можно будет аналогичным образом прочитать таблицу по столбцам. Текст в ячейках таблицы тоже состоит из абзацев. Если мы захотим проанализировать абзацы или фрагменты внутри ячейки, то можно будет воспользоваться всеми методами объектов Paragraph и Run.

Часто может понадобиться проанализировать только таблицы, содержащие определённые заголовки. Попробуем, например, выделить из документа только таблицы, у которых в строке заголовка присутствуют названия Продукт и Стоимость. Для таких таблиц построчно распечатаем все значения из ячеек:

for table in doc.tables:
    for index, row in enumerate(table.rows):
        if index == 0:
            row_text = list(cell.text for cell in row.cells)
            if 'Продукт' not in row_text or 'Стоимость' not in row_text:
                break
        for cell in row.cells:
            print(cell.text)

Также нам может понадобиться определить, какие из ячеек в таблице являются объединёнными. Стандартной функции для этого нет, однако мы можем воспользоваться тем, что нам доступно положение ячейки от каждого из краев таблицы:

for table in doc.tables:
    unique, merged = set(), set()
    for row in table.rows:
        for cell in row.cells:
            tc = cell._tc
            cell_loc = (tc.top, tc.bottom, tc.left, tc.right)
            if cell_loc in unique:
                merged.add(cell_loc)
            else:
                unique.add(cell_loc)
    print(merged)

Воспользовавшись этим кодом, можно получить все координаты объединённых ячеек для каждой из таблиц документа. Кроме того, разница координат tc.top и tc.bottom показывает, сколько строк в объединённой ячейке, а разница tc.left и tc.right – сколько столбцов.

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

import re

pattern = re.compile('w:fill=\"(\S*)\"')
for table in doc.tables:
    for row in table.rows:
        for cell in row.cells:
            match = pattern.search(cell._tc.xml)
            if match:
                if match.group(1) == 'FFFF00':
                    print(cell.text)

В этом блоке кода мы выделили только те ячейки, фон которых был окрашен в жёлтый цвет (#FFFF00 в формате RGB).

Работа с иллюстрациями

В библиотеке docx также реализована возможность работы с иллюстрациями документа. Стандартными способами можно посмотреть только на размеры изображений:

for shape in doc.inline_shapes:
    print(shape.width, shape.height)

Однако при помощи сторонней библиотеки docx2txt и анализа xml-кода абзацев становится возможным не только выгрузить все иллюстрации документов, но и определить, в каком именно абзаце они встречались:

import os
import docx
import docx2txt

for path in paths:
    splitted = os.path.split(path)
    folders = [os.path.splitext(splitted[1])[0]]
    while splitted[0]:
        splitted = os.path.split(splitted[0])
        folders.insert(0, splitted[1])

    images_path = os.path.join('images', *folders)
    os.makedirs(images_path, exist_ok=True)

    doc = docx.Document(path)
    docx2txt.process(path, images_path)
    
    rels = {}
    for rel in doc.part.rels.values():
        if isinstance(rel._target, docx.parts.image.ImagePart):
            rels[rel.rId] = os.path.basename(rel._target.partname)
    
    for paragraph in doc.paragraphs:
        if 'Graphic' in paragraph._p.xml:
            for rId in rels:
                if rId in paragraph._p.xml:
                    print(os.path.join(images_path, rels[rId]))
                    print(paragraph.text)

В этом блоке мы выводим путь к изображению, которое сохранено на диске, и текст параграфа, в котором встретилось изображение. Все изображения находятся внутри директории images, а именно — в поддиректориях, соответствующих расположению исходного файла Word.