Как массово преобразовать DOCM в DOCX: готовое решение

от автора

Вот было у Вас такое, что на вход реализованному на Python автоматизированному процессу, принципиально не использующему приложение Microsoft Word и COM-объекты, нет-нет да и придет файл с расширением docm? Ведь фактически, чтобы обращаться к текстовым файлам, на текущий момент в экосистеме Python существует только одна более-менее функциональная (и, что греха таить, бесплатная) библиотека – python-docx. А она позволяет работать только с docx-файлами. Можно, конечно, задействовать Word и выполнить преобразование путем пересохранения, но мы же помним – принципы (прошу не считать это глупостью, просто если можно работать с файлами напрямую – лучше так и делать). Можно взять другое приложение, взаимодействуя с ним посредством GUI через использование pywinauto и pyautogui, но здесь получается все не всегда просто и не быстро: заменили приложение, у приложения изменился интерфейс – начинай сначала…

Заказчику, меж тем, нет дела до наших трудностей: его файл(ы) автоматизированный процесс должен уметь обрабатывать. К счастью, doc-файлы уже не в ходу, иначе бы дальше говорить было не о чем.

Так вот, памятуя о том, что и формат docx, и формат docm – суть архивы xml-файлов (да, там есть еще некие .rels, но внутри они – те же xml’ки), мы задались вопросом: а что если преобразование одного формата в другой реализовать на файловом уровне? Вот прям забраться внутрь и внести необходимые изменения?

Сказано – сделано! Вот скрипт:

import osimport zipfileimport redef docm2docx_convert(input_file: str, output_file: str, info=False) -> bool:    """    Преобразование docm-файла в docx (файловая обработка, приложения не используются)        Параметры:        input_file: str - полный путь к преобразуемому docm-файлу        output_file: str - полный путь к преобразованному docx-файлу        info: bool - флаг вывода в консоль информации о результатах преобразования    """    if str(input_file).lower()[-4:] != 'docm':        if info:            print(f'{os.path.basename(input_file)} - это не docm-файл, преобразование невозможно')        return False    # распаковка zip-архива, каковым является docm-файл    input_zip = zipfile.ZipFile(input_file)    all_content_dct = {name: input_zip.read(name) for name in input_zip.namelist()}        # удаление тех xml-файлов, которые не нужны в docx-файле    files2del_lst = ['vbaData.xml', 'vbaProject.bin', 'vbaProject.bin.rels']    for xml_file_as_key in list(all_content_dct.keys()):        if [file2del for file2del in files2del_lst if file2del in xml_file_as_key]:            del all_content_dct[xml_file_as_key]    for xml_file_as_key, xml_content_as_value in all_content_dct.items():        # обработка '[Content_Types].xml'        if xml_file_as_key == '[Content_Types].xml':            xml_content_as_str = xml_content_as_value.decode(encoding='utf-8')            xml_content_as_lst = re.findall(r'<.+?>', xml_content_as_str)            # удаление ненужных элементов            for xml_element in reversed(xml_content_as_lst):                if [el2del for el2del in (                        'vnd.ms-office.vbaProject', 'vnd.ms-word.vbaData+xml',                        ) if el2del in xml_element]:                    xml_content_as_lst.remove(xml_element)            # изменение типа документа            for i, xml_element in enumerate(xml_content_as_lst):                if 'vnd.ms-word.document.macroEnabled.main+xml' in xml_element:                    xml_element_with_repl = xml_element.replace(                        'vnd.ms-word.document.macroEnabled',                         'vnd.openxmlformats-officedocument.wordprocessingml.document'                        )                    xml_content_as_lst[i] = xml_element_with_repl            # добавление элементов            checked_element = '<Default Extension="emf" ContentType="image/x-emf"/>'            if checked_element not in xml_content_as_lst:                xml_content_as_lst.insert(2, checked_element)            checked_element = ('<Override PartName="/word/stylesWithEffects.xml" '                               'ContentType="application/vnd.ms-word.stylesWithEffects+xml"/>')            if checked_element not in xml_content_as_lst:                xml_content_as_lst.insert(-1, checked_element)            all_content_dct[xml_file_as_key] = ''.join(xml_content_as_lst).encode(encoding='utf-8')        # обработка 'word/_rels/document.xml.rels'        if xml_file_as_key == 'word/_rels/document.xml.rels':            xml_content_as_str = xml_content_as_value.decode(encoding='utf-8')            xml_content_as_lst = re.findall(r'<.+?>', xml_content_as_str)            # удаление ненужного элемента            for xml_element in reversed(xml_content_as_lst):                if 'Target="vbaProject.bin"' in xml_element:                    xml_content_as_lst.remove(xml_element)            # обновление нумерации идентификаторов            r_id = 1            for i, xml_element in enumerate(xml_content_as_lst):                r_id_search = re.findall(r'Id="rId\d"', xml_element)                if r_id_search:                    xml_element_with_repl = xml_element.replace(r_id_search[0], f'Id="rId{r_id}"')                    xml_content_as_lst[i] = xml_element_with_repl                    r_id += 1            all_content_dct[xml_file_as_key] = ''.join(xml_content_as_lst).encode(encoding='utf-8')    # упаковка архива в docx-файл    with zipfile.ZipFile(output_file, mode='w') as conv_file:        for xml_filename, xml_content in all_content_dct.items():            conv_file.writestr(zinfo_or_arcname=xml_filename, data=xml_content, compress_type=zipfile.ZIP_DEFLATED)       if info:        print(f'Файл "{os.path.basename(input_file)}" преобразован в "{os.path.basename(output_file)}"')    return True

Следует отметить, что макросы, которые могли пробраться в docm-файлы заказчика, мы в своем автоматизированном процессе не используем. Равно как и заказчику они уже больше не нужны. Другими словами, все макросодержимое можно было выпиливать на корню, что значительно упрощало задачу.

Docm-файлы для проверки создавались в Microsoft Office 2007, Microsoft Office 2010 и Microsoft Office 2016, а также в LibreOffice 7.5.3.2 и LibreOffice 26.8.0.3 (ну что было под рукой). Версия модуля python-docx, который потом успешно прочитал полученные docx-файлы: 1.1.2.

Интересно, что LibreOffice VBA-макросы внутри *.docm, не сохраняет, но файл с таким расширением создает успешно. Поэтому надежды на импортозамещение, которое сотрет с наших компьютеров все, что относится к Microsoft, и окончательно остановит поток файлов с макросами, слегка поблекли.

Несколько моментов в копилку с каждым днем становящихся все более бесполезными знаний, дополнительно поясняющих код:

1) Модуль python-docxперед открытием проверяет не расширение файла (поскольку просто переименованный в docm «чистый» docx он без проблем читает, хотя Word«ругается»), а вот этот xml-элемент в [Content_Types].xml:

<Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/>

В docm-файле этот элемент выглядит так:

<Override PartName="/word/document.xml" ContentType="application/vnd.ms-word.document.macroEnabled.main+xml"/>

Если не выполнить замену второго на первый, то даже без макросов docx-файл модулем python-docx никак не прочитать.

2) Файл с макросом, созданный в Microsoft Office 2007, в том же [Content_Types].xml не содержит следующий элемент:

<Default Extension="emf" ContentType="image/x-emf"/>

Модуль python-docx без этого отказывается читать docx, даже если тот клянется Матрицей, что не содержит макросы. Интернет посоветовал включить «заглушку»:

<Override PartName="/docProps/thumbnail.emf" ContentType="application/octet-stream"/>

Но потом оказалось, что 2010-й и 2016 офисные пакеты добавляют свой элемент, который конфликтует с вышеуказанным:

<Default Extension="emf" ContentType="image/x-emf"/>

В общем, наличие этого элемента необходимо проверять, если его нет – добавлять.

3) Архив xml’ей, создаваемый Word в 2007-м и 2016-м «офисах» содержит структуру из шести файлов: settings.xml, stylesWithEffects.xml, styles.xml, theme1.xml, fontTable.xml, webSettings.xml, а вот 2010-й пакет добавляет еще два файла: endnotes.xml и footnotes.xml. Да, это все «внутренняя кухня», сильно техническая, но с точки зрения информационной безопасности – настораживает…

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

P.S. Не вайбкодинг!

ссылка на оригинал статьи https://habr.com/ru/articles/1082756/