my_converterbot/md_to_docx.py
2025-09-26 01:40:17 +03:00

622 lines
No EOL
26 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
import re
import sys
from pathlib import Path
from docx import Document
from docx.shared import Inches, Pt, RGBColor, Cm
from docx.enum.text import WD_ALIGN_PARAGRAPH, WD_LINE_SPACING
from docx.enum.style import WD_STYLE_TYPE
from docx.enum.section import WD_SECTION
from docx.oxml.shared import OxmlElement, qn
from docx.oxml.ns import nsdecls
from docx.oxml import parse_xml
class DocumentSettings:
"""Настройки форматирования документа с поддержкой ГОСТ"""
def __init__(self):
# Базовые настройки текста
self.font_name = "Times New Roman"
self.font_size = 14 # основной текст
self.line_spacing = 1.5
self.justify_text = True
self.paragraph_spacing = 6
self.text_color = (0, 0, 0)
self.paragraph_indent = 1.25
# Отступы от полей документа в сантиметрах (ГОСТ 7.32-2017)
self.margin_top = 2.0
self.margin_bottom = 2.0
self.margin_left = 3.0 # увеличено для переплета
self.margin_right = 1.5
# Настройки шрифтов заголовков по ГОСТ
self.heading1_font_size = 16 # Заголовки глав
self.heading2_font_size = 14 # Заголовки разделов
self.heading3_font_size = 14 # Подзаголовки
self.heading4_font_size = 14
self.heading5_font_size = 12
self.heading6_font_size = 12
self.footnote_font_size = 10
# Интервалы заголовков по ГОСТ
self.heading_spacing_before = 12 # пт
self.heading_spacing_after = 6 # пт
self.paragraph_spacing_before = 0 # пт
# Нумерация страниц
self.page_numbering = True
self.page_number_position = "bottom_center" # top_right, bottom_center, bottom_right
self.page_number_start = 1
self.exclude_title_page_numbering = True
# Автонумерация заголовков
self.auto_numbering_headings = False
self.numbering_format = "decimal" # "decimal" (1.1.1) или "simple" (1)
# Дополнительные ГОСТ настройки
self.bibliography_style = "gost"
self.table_caption_position = "above" # above, below
self.figure_caption_position = "below"
class MarkdownToDocxConverter:
"""Конвертер Markdown в DOCX с поддержкой ГОСТ"""
def __init__(self, settings: DocumentSettings = None):
self.settings = settings or DocumentSettings()
self.doc = Document()
# Счетчики для автонумерации
self.heading_counters = [0] * 6 # для 6 уровней заголовков
self.footnote_counter = 0
self.table_counter = 0
self.figure_counter = 0
self.setup_document_margins()
self.setup_page_numbering()
self.setup_styles()
def setup_document_margins(self):
"""Настройка отступов от полей документа по ГОСТ"""
sections = self.doc.sections
for section in sections:
section.top_margin = Cm(self.settings.margin_top)
section.bottom_margin = Cm(self.settings.margin_bottom)
section.left_margin = Cm(self.settings.margin_left)
section.right_margin = Cm(self.settings.margin_right)
def setup_page_numbering(self):
"""Настройка нумерации страниц согласно ГОСТ"""
if not self.settings.page_numbering:
return
section = self.doc.sections[0]
# Создание колонтитула для нумерации
if self.settings.page_number_position == "bottom_center":
footer = section.footer
footer_para = footer.paragraphs[0]
footer_para.alignment = WD_ALIGN_PARAGRAPH.CENTER
elif self.settings.page_number_position == "top_right":
header = section.header
header_para = header.paragraphs[0]
header_para.alignment = WD_ALIGN_PARAGRAPH.RIGHT
elif self.settings.page_number_position == "bottom_right":
footer = section.footer
footer_para = footer.paragraphs[0]
footer_para.alignment = WD_ALIGN_PARAGRAPH.RIGHT
def setup_styles(self):
"""Настройка стилей документа в соответствии с ГОСТ"""
styles = self.doc.styles
# Настройка базового стиля
normal_style = styles['Normal']
normal_font = normal_style.font
normal_font.name = self.settings.font_name
normal_font.size = Pt(self.settings.font_size)
normal_font.color.rgb = RGBColor(*self.settings.text_color)
normal_paragraph = normal_style.paragraph_format
normal_paragraph.line_spacing_rule = WD_LINE_SPACING.MULTIPLE
normal_paragraph.line_spacing = self.settings.line_spacing
normal_paragraph.space_after = Pt(self.settings.paragraph_spacing)
normal_paragraph.space_before = Pt(self.settings.paragraph_spacing_before)
normal_paragraph.first_line_indent = Cm(self.settings.paragraph_indent)
if self.settings.justify_text:
normal_paragraph.alignment = WD_ALIGN_PARAGRAPH.JUSTIFY
# Настройка стилей заголовков с дифференцированными размерами
heading_sizes = [
self.settings.heading1_font_size,
self.settings.heading2_font_size,
self.settings.heading3_font_size,
self.settings.heading4_font_size,
self.settings.heading5_font_size,
self.settings.heading6_font_size
]
for i in range(1, 7):
heading_style_name = f'Heading {i}'
if heading_style_name in [s.name for s in styles]:
heading_style = styles[heading_style_name]
else:
heading_style = styles.add_style(heading_style_name, WD_STYLE_TYPE.PARAGRAPH)
heading_font = heading_style.font
heading_font.name = self.settings.font_name
heading_font.size = Pt(heading_sizes[i-1]) # используем соответствующий размер
heading_font.bold = True
heading_font.color.rgb = RGBColor(*self.settings.text_color)
heading_paragraph = heading_style.paragraph_format
heading_paragraph.space_before = Pt(self.settings.heading_spacing_before)
heading_paragraph.space_after = Pt(self.settings.heading_spacing_after)
# Заголовки 1 и 2 уровня по центру (ГОСТ), остальные с отступом
if i <= 2:
heading_paragraph.alignment = WD_ALIGN_PARAGRAPH.CENTER
heading_paragraph.first_line_indent = Cm(0)
else:
if self.settings.justify_text:
heading_paragraph.alignment = WD_ALIGN_PARAGRAPH.JUSTIFY
heading_paragraph.first_line_indent = Cm(self.settings.paragraph_indent)
# Стиль для сносок
try:
footnote_style = styles.add_style('Footnote', WD_STYLE_TYPE.PARAGRAPH)
footnote_font = footnote_style.font
footnote_font.name = self.settings.font_name
footnote_font.size = Pt(self.settings.footnote_font_size)
footnote_font.color.rgb = RGBColor(*self.settings.text_color)
footnote_paragraph = footnote_style.paragraph_format
footnote_paragraph.space_before = Pt(3)
footnote_paragraph.space_after = Pt(3)
footnote_paragraph.first_line_indent = Cm(0.5)
except:
pass
# Стиль для кода (без изменений)
try:
code_style = styles.add_style('Code', WD_STYLE_TYPE.CHARACTER)
code_font = code_style.font
code_font.name = 'Courier New'
code_font.size = Pt(self.settings.font_size)
code_font.color.rgb = RGBColor(*self.settings.text_color)
except:
pass
# Стиль для блоков кода
try:
code_block_style = styles.add_style('Code Block', WD_STYLE_TYPE.PARAGRAPH)
code_block_font = code_block_style.font
code_block_font.name = 'Courier New'
code_block_font.size = Pt(self.settings.font_size)
code_block_font.color.rgb = RGBColor(*self.settings.text_color)
code_block_paragraph = code_block_style.paragraph_format
code_block_paragraph.left_indent = Inches(0.5)
code_block_paragraph.first_line_indent = Cm(0) # без отступа первой строки для кода
code_block_paragraph.space_before = Pt(6)
code_block_paragraph.space_after = Pt(6)
except:
pass
# Стиль для подписей к таблицам и рисункам
try:
caption_style = styles.add_style('Caption', WD_STYLE_TYPE.PARAGRAPH)
caption_font = caption_style.font
caption_font.name = self.settings.font_name
caption_font.size = Pt(self.settings.font_size - 2) # меньше основного текста
caption_font.color.rgb = RGBColor(*self.settings.text_color)
caption_paragraph = caption_style.paragraph_format
caption_paragraph.alignment = WD_ALIGN_PARAGRAPH.CENTER
caption_paragraph.space_before = Pt(6)
caption_paragraph.space_after = Pt(6)
except:
pass
def generate_heading_number(self, level: int) -> str:
"""Генерация номера заголовка согласно настройкам автонумерации"""
if not self.settings.auto_numbering_headings:
return ""
# Обновляем счетчик текущего уровня
self.heading_counters[level - 1] += 1
# Обнуляем счетчики всех нижестоящих уровней
for i in range(level, 6):
self.heading_counters[i] = 0
if self.settings.numbering_format == "simple":
return f"{self.heading_counters[level - 1]}. "
else: # decimal
# Формируем иерархическую нумерацию
numbers = []
for i in range(level):
if self.heading_counters[i] > 0:
numbers.append(str(self.heading_counters[i]))
return ".".join(numbers) + ". " if numbers else ""
def parse_markdown_file(self, file_path: str):
"""Чтение и парсинг Markdown файла"""
try:
with open(file_path, 'r', encoding='utf-8') as file:
content = file.read()
return content
except Exception as e:
raise Exception(f"Ошибка чтения файла: {e}")
def add_text_run_with_color(self, paragraph, text, bold=False, italic=False, code_style=False):
"""Добавление текста с настройкой цвета"""
run = paragraph.add_run(text)
run.font.color.rgb = RGBColor(*self.settings.text_color)
if bold:
run.font.bold = True
if italic:
run.font.italic = True
if code_style:
run.style = 'Code'
return run
def process_text_formatting(self, text: str, paragraph):
"""Обработка форматирования текста включая сноски [^1]"""
# Обработка сносок
footnote_pattern = r'\[\^(\d+)\]'
footnotes = re.findall(footnote_pattern, text)
# Заменяем сноски на верхние индексы
for footnote_num in footnotes:
text = re.sub(rf'\[\^{footnote_num}\]', f'{{FOOTNOTE_{footnote_num}}}', text)
# Разбор текста на части с различным форматированием
parts = re.split(r'(\*\*.*?\*\*|\*.*?\*|`.*?`|\{FOOTNOTE_\d+\})', text)
for part in parts:
if not part:
continue
if part.startswith('**') and part.endswith('**'):
# Жирный текст
self.add_text_run_with_color(paragraph, part[2:-2], bold=True)
elif part.startswith('*') and part.endswith('*'):
# Курсив
self.add_text_run_with_color(paragraph, part[1:-1], italic=True)
elif part.startswith('`') and part.endswith('`'):
# Инлайн код
self.add_text_run_with_color(paragraph, part[1:-1], code_style=True)
elif part.startswith('{FOOTNOTE_') and part.endswith('}'):
# Сноска - добавляем как верхний индекс
footnote_num = re.search(r'FOOTNOTE_(\d+)', part).group(1)
run = self.add_text_run_with_color(paragraph, footnote_num)
run.font.superscript = True
else:
# Обычный текст
self.add_text_run_with_color(paragraph, part)
def process_list(self, lines: list, start_idx: int):
"""Обработка списков с правильным форматированием по ГОСТ"""
i = start_idx
list_items = []
while i < len(lines):
line = lines[i].strip()
if re.match(r'^[-*+]\s', line):
item_text = re.sub(r'^[-*+]\s', '', line)
list_items.append(('bullet', item_text, 0))
elif re.match(r'^\d+\.\s', line):
item_text = re.sub(r'^\d+\.\s', '', line)
list_items.append(('number', item_text, 0))
elif re.match(r'^ [-*+]\s', line):
item_text = re.sub(r'^ [-*+]\s', '', line)
list_items.append(('bullet', item_text, 1))
elif re.match(r'^ \d+\.\s', line):
item_text = re.sub(r'^ \d+\.\s', '', line)
list_items.append(('number', item_text, 1))
elif line == '':
i += 1
continue
else:
break
i += 1
# Добавление элементов списка с настройками ГОСТ
for list_type, text, level in list_items:
paragraph = self.doc.add_paragraph()
paragraph.paragraph_format.left_indent = Cm(level * 0.75) # увеличенный отступ для вложенности
paragraph.paragraph_format.first_line_indent = Cm(self.settings.paragraph_indent)
if list_type == 'bullet':
paragraph.style = 'List Bullet'
# Используем тире вместо точек (согласно ГОСТ)
bullet_run = paragraph.runs[0] if paragraph.runs else paragraph.add_run()
bullet_run.text = " " # длинное тире
else:
paragraph.style = 'List Number'
self.process_text_formatting(text, paragraph)
return i - 1
def process_table(self, lines: list, start_idx: int):
"""Обработка таблиц с подписями согласно ГОСТ"""
i = start_idx
table_lines = []
while i < len(lines):
line = lines[i].strip()
if '|' in line:
table_lines.append(line)
elif line == '':
i += 1
continue
else:
break
i += 1
if len(table_lines) < 2:
return start_idx
# Добавляем подпись к таблице (если настроено)
if self.settings.table_caption_position == "above":
self.table_counter += 1
caption_para = self.doc.add_paragraph()
caption_para.style = 'Caption'
caption_para.add_run(f"Таблица {self.table_counter}")
# Парсинг и создание таблицы
headers = [cell.strip() for cell in table_lines[0].split('|')[1:-1]]
data_lines = table_lines[2:] if len(table_lines) > 2 else []
table = self.doc.add_table(rows=1, cols=len(headers))
table.style = 'Table Grid'
# Заполнение заголовков
header_row = table.rows[0]
for idx, header in enumerate(headers):
cell = header_row.cells[idx]
cell.text = header
for paragraph in cell.paragraphs:
paragraph.alignment = WD_ALIGN_PARAGRAPH.CENTER
for run in paragraph.runs:
run.font.bold = True
# Заполнение данных
for line in data_lines:
row_data = [cell.strip() for cell in line.split('|')[1:-1]]
row = table.add_row()
for idx, cell_data in enumerate(row_data):
if idx < len(row.cells):
row.cells[idx].text = cell_data
# Выравнивание по центру для всех ячеек (ГОСТ)
for paragraph in row.cells[idx].paragraphs:
paragraph.alignment = WD_ALIGN_PARAGRAPH.CENTER
# Подпись снизу (если настроено)
if self.settings.table_caption_position == "below":
self.table_counter += 1
caption_para = self.doc.add_paragraph()
caption_para.style = 'Caption'
caption_para.add_run(f"Таблица {self.table_counter}")
return i - 1
def process_code_block(self, lines: list, start_idx: int):
"""Обработка блоков кода"""
i = start_idx + 1
code_lines = []
while i < len(lines):
line = lines[i]
if line.strip().startswith('```'):
break
code_lines.append(line)
i += 1
code_paragraph = self.doc.add_paragraph()
code_paragraph.style = 'Code Block'
code_paragraph.add_run('\n'.join(code_lines))
return i
def add_footnote_definition(self, footnote_num: str, footnote_text: str):
"""Добавление определения сноски в конец документа"""
footnote_para = self.doc.add_paragraph()
footnote_para.style = 'Footnote'
# Номер сноски как верхний индекс
footnote_run = footnote_para.add_run(footnote_num)
footnote_run.font.superscript = True
# Текст сноски
footnote_para.add_run(f" {footnote_text}")
def process_bibliography(self, lines: list, start_idx: int):
"""Обработка списка литературы в стиле ГОСТ"""
i = start_idx
bib_items = []
# Поиск элементов библиографии
while i < len(lines):
line = lines[i].strip()
if re.match(r'^\d+\.\s', line):
bib_text = re.sub(r'^\d+\.\s', '', line)
bib_items.append(bib_text)
elif line == '':
i += 1
continue
else:
break
i += 1
if bib_items:
# Заголовок списка литературы
bib_heading = self.doc.add_paragraph()
bib_heading.style = 'Heading 1'
bib_heading.add_run("СПИСОК ЛИТЕРАТУРЫ")
# Элементы библиографии
for idx, item in enumerate(bib_items, 1):
bib_para = self.doc.add_paragraph()
bib_para.paragraph_format.first_line_indent = Cm(0)
bib_para.paragraph_format.left_indent = Cm(1)
bib_para.add_run(f"{idx}. {item}")
return i - 1
def convert(self, md_file_path: str, output_path: str = None):
"""Основной метод конвертации с поддержкой ГОСТ"""
if not output_path:
md_path = Path(md_file_path)
output_path = md_path.with_suffix('.docx')
content = self.parse_markdown_file(md_file_path)
lines = content.split('\n')
# Сбор сносок для обработки в конце
footnote_definitions = {}
i = 0
while i < len(lines):
line = lines[i]
stripped_line = line.strip()
if not stripped_line:
i += 1
continue
# Обработка определений сносок [^1]: текст сноски
footnote_def_match = re.match(r'^\[\^(\d+)\]:\s*(.+)', stripped_line)
if footnote_def_match:
footnote_num = footnote_def_match.group(1)
footnote_text = footnote_def_match.group(2)
footnote_definitions[footnote_num] = footnote_text
i += 1
continue
# Заголовки с автонумерацией
if stripped_line.startswith('#'):
match = re.match(r'^(#{1,6})\s+(.+)', stripped_line)
if match:
level = len(match.group(1))
title = match.group(2)
# Разрыв страницы перед заголовком 2 уровня
if level == 2:
self.doc.add_page_break()
heading = self.doc.add_paragraph()
heading.style = f'Heading {level}'
# Добавляем автонумерацию
heading_number = self.generate_heading_number(level)
full_title = heading_number + title
self.process_text_formatting(full_title, heading)
# Блоки кода
elif stripped_line.startswith('```'):
i = self.process_code_block(lines, i)
# Таблицы
elif '|' in stripped_line:
i = self.process_table(lines, i)
# Списки
elif re.match(r'^[-*+]\s', stripped_line) or re.match(r'^\d+\.\s', stripped_line):
i = self.process_list(lines, i)
# Список литературы (если заголовок содержит "литература" или "bibliography")
elif re.match(r'^#+\s*(список\s+литературы|bibliography|references)', stripped_line, re.IGNORECASE):
i = self.process_bibliography(lines, i + 1)
# Цитаты
elif stripped_line.startswith('>'):
quote_text = re.sub(r'^>\s?', '', stripped_line)
quote_paragraph = self.doc.add_paragraph()
quote_paragraph.paragraph_format.left_indent = Inches(0.5)
quote_paragraph.paragraph_format.right_indent = Inches(0.5)
self.process_text_formatting(quote_text, quote_paragraph)
for run in quote_paragraph.runs:
run.font.italic = True
# Горизонтальные линии
elif stripped_line in ['---', '***', '___']:
hr_paragraph = self.doc.add_paragraph()
hr_paragraph.add_run('_' * 50)
hr_paragraph.alignment = WD_ALIGN_PARAGRAPH.CENTER
# Обычные абзацы
else:
paragraph = self.doc.add_paragraph()
self.process_text_formatting(stripped_line, paragraph)
i += 1
# Добавление сносок в конец документа
if footnote_definitions:
# Разделительная линия
self.doc.add_paragraph().add_run('_' * 50)
for footnote_num in sorted(footnote_definitions.keys(), key=int):
self.add_footnote_definition(footnote_num, footnote_definitions[footnote_num])
self.doc.save(output_path)
return output_path
def main():
"""Основная функция для запуска из командной строки"""
if len(sys.argv) < 2:
print("Использование: python md_converter.py <путь_к_md_файлу> [путь_к_выходномуайлу]")
return
md_file = sys.argv[1]
output_file = sys.argv[2] if len(sys.argv) > 2 else None
# ГОСТ-совместимые настройки по умолчанию
settings = DocumentSettings()
converter = MarkdownToDocxConverter(settings)
try:
output_path = converter.convert(md_file, output_file)
print(f"Файл успешно конвертирован: {output_path}")
except Exception as e:
print(f"Ошибка конвертации: {e}")
if __name__ == "__main__":
main()
# Пример использования с кастомными ГОСТ настройками:
"""
settings = DocumentSettings()
settings.font_name = "Times New Roman"
settings.font_size = 14
settings.heading1_font_size = 16
settings.heading2_font_size = 14
settings.line_spacing = 1.5
settings.margin_left = 3.0 # для переплета
settings.auto_numbering_headings = True
settings.numbering_format = "decimal" # 1.1.1 формат
settings.page_numbering = True
settings.page_number_position = "bottom_center"
converter = MarkdownToDocxConverter(settings)
converter.convert("dissertation.md", "dissertation_gost.docx")
"""