Время прочтения: 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 – ячейки в столбце
- Список объектов Row – строки таблицы
- Список объектов InlineShape – иллюстрации документа
- Список объектов Paragraph – абзацы документа
Работа с текстом документа
Для начала давайте разберёмся, как работать с текстом документа. В библиотеке 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.