1
0
Fork 0
mirror of https://github.com/EDeev/converterbot.git synced 2026-10-07 20:49:33 +03:00

md2gost: исправлено оформление по ГОСТ

- номеров страниц не было: колонтитул создавался, но поле PAGE в него не добавлялось;
  теперь номер внизу по центру, на титульном листе — без номера;
- заголовки выходили не в Times New Roman: встроенные стили Word задают шрифт темы
  (asciiTheme), который перекрывает font.name — атрибуты темы снимаются у всех стилей;
- подписи таблиц брали встроенный стиль Caption (синий, 9 пт, курсив) — стиль
  настраивается, подпись «Таблица N» — слева над таблицей, как в ГОСТ 7.32;
- маркированный список получал два маркера (стиль List Bullet и «–»), вложенность
  терялась, а нумерованный список продолжал счёт предыдущего; теперь тире, отступ по
  уровню и своя нумерация «1)» у каждого списка;
- «Введение», «Заключение», «Список литературы» и другие структурные элементы — без
  номера, прописными; ветка списка литературы раньше была недостижима;
- таблица заканчивается на пустой строке (две таблицы подряд сливались), строка с «|» без
  разделителя — обычный абзац, внешние «|» необязательны; в ячейках — без абзацного
  отступа и с одинарным интервалом;
- файлы с BOM читаются корректно.
This commit is contained in:
Деев Егор Викторович 2026-10-05 23:24:29 +00:00
parent bc351e995a
commit 18e1a99ed8

View file

@ -1,17 +1,52 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
import re import re
import sys
from pathlib import Path from pathlib import Path
from docx import Document 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.style import WD_STYLE_TYPE
from docx.enum.section import WD_SECTION from docx.enum.text import WD_ALIGN_PARAGRAPH, WD_LINE_SPACING
from docx.oxml.shared import OxmlElement, qn from docx.oxml.shared import OxmlElement, qn
from docx.oxml.ns import nsdecls from docx.shared import Cm, Inches, Pt, RGBColor
from docx.oxml import parse_xml
# Структурные элементы по ГОСТ 7.32-2017 — заголовки без номера
STRUCTURAL_HEADINGS = re.compile(
r"^(реферат|содержание|оглавление|введение|заключение|список\s+(использованных\s+)?(литературы|источников)"
r"|библиография|bibliography|references|приложени[ея].*|термины\s+и\s+определения"
r"|перечень\s+сокращений.*|определения|обозначения\s+и\s+сокращения)$",
re.IGNORECASE,
)
BIBLIOGRAPHY_HEADING = re.compile(
r"^(список\s+(использованных\s+)?(литературы|источников)|библиография|bibliography|references)$",
re.IGNORECASE,
)
def set_style_font(style, font_name):
"""Шрифт стиля для всех письменностей. Встроенные стили Word (заголовки) задают шрифт темы
(asciiTheme и т. п.), который перекрывает font.name — без очистки заголовки выходят в Calibri"""
style.font.name = font_name
rpr = style.element.get_or_add_rPr()
rfonts = rpr.find(qn("w:rFonts"))
if rfonts is None:
rfonts = OxmlElement("w:rFonts")
rpr.append(rfonts)
for attr in ("w:asciiTheme", "w:hAnsiTheme", "w:eastAsiaTheme", "w:cstheme"):
rfonts.attrib.pop(qn(attr), None)
for attr in ("w:ascii", "w:hAnsi", "w:eastAsia", "w:cs"):
rfonts.set(qn(attr), font_name)
def add_page_field(paragraph):
"""Поле PAGE — номер страницы, который Word подставляет сам"""
run = paragraph.add_run()
begin, instr, end = OxmlElement("w:fldChar"), OxmlElement("w:instrText"), OxmlElement("w:fldChar")
begin.set(qn("w:fldCharType"), "begin")
instr.set(qn("xml:space"), "preserve")
instr.text = "PAGE"
end.set(qn("w:fldCharType"), "end")
run._r.append(begin)
run._r.append(instr)
run._r.append(end)
return run
class DocumentSettings: class DocumentSettings:
@ -95,22 +130,24 @@ class MarkdownToDocxConverter:
return return
section = self.doc.sections[0] section = self.doc.sections[0]
# Создание колонтитула для нумерации # Создание колонтитула для нумерации (раньше колонтитул создавался, но поле номера
if self.settings.page_number_position == "bottom_center": # страницы в него не добавлялось — номеров в документе не было)
footer = section.footer if self.settings.page_number_position == "top_right":
footer_para = footer.paragraphs[0] para = section.header.paragraphs[0]
footer_para.alignment = WD_ALIGN_PARAGRAPH.CENTER para.alignment = WD_ALIGN_PARAGRAPH.RIGHT
else:
elif self.settings.page_number_position == "top_right": para = section.footer.paragraphs[0]
header = section.header para.alignment = (WD_ALIGN_PARAGRAPH.RIGHT if self.settings.page_number_position == "bottom_right"
header_para = header.paragraphs[0] else WD_ALIGN_PARAGRAPH.CENTER)
header_para.alignment = WD_ALIGN_PARAGRAPH.RIGHT para.paragraph_format.first_line_indent = Cm(0)
run = add_page_field(para)
elif self.settings.page_number_position == "bottom_right": run.font.name = self.settings.font_name
footer = section.footer run.font.size = Pt(self.settings.font_size)
footer_para = footer.paragraphs[0]
footer_para.alignment = WD_ALIGN_PARAGRAPH.RIGHT # титульный лист без номера: у первой страницы свой, пустой колонтитул
if self.settings.exclude_title_page_numbering:
section.different_first_page_header_footer = True
def setup_styles(self): def setup_styles(self):
"""Настройка стилей документа в соответствии с ГОСТ""" """Настройка стилей документа в соответствии с ГОСТ"""
@ -119,7 +156,7 @@ class MarkdownToDocxConverter:
# Настройка базового стиля # Настройка базового стиля
normal_style = styles['Normal'] normal_style = styles['Normal']
normal_font = normal_style.font normal_font = normal_style.font
normal_font.name = self.settings.font_name set_style_font(normal_style, self.settings.font_name)
normal_font.size = Pt(self.settings.font_size) normal_font.size = Pt(self.settings.font_size)
normal_font.color.rgb = RGBColor(*self.settings.text_color) normal_font.color.rgb = RGBColor(*self.settings.text_color)
@ -151,9 +188,10 @@ class MarkdownToDocxConverter:
heading_style = styles.add_style(heading_style_name, WD_STYLE_TYPE.PARAGRAPH) heading_style = styles.add_style(heading_style_name, WD_STYLE_TYPE.PARAGRAPH)
heading_font = heading_style.font heading_font = heading_style.font
heading_font.name = self.settings.font_name set_style_font(heading_style, self.settings.font_name)
heading_font.size = Pt(heading_sizes[i-1]) # используем соответствующий размер heading_font.size = Pt(heading_sizes[i-1]) # используем соответствующий размер
heading_font.bold = True heading_font.bold = True
heading_font.italic = False
heading_font.color.rgb = RGBColor(*self.settings.text_color) heading_font.color.rgb = RGBColor(*self.settings.text_color)
heading_paragraph = heading_style.paragraph_format heading_paragraph = heading_style.paragraph_format
@ -181,24 +219,24 @@ class MarkdownToDocxConverter:
footnote_paragraph.space_before = Pt(3) footnote_paragraph.space_before = Pt(3)
footnote_paragraph.space_after = Pt(3) footnote_paragraph.space_after = Pt(3)
footnote_paragraph.first_line_indent = Cm(0.5) footnote_paragraph.first_line_indent = Cm(0.5)
except: except ValueError: # стиль уже есть
pass pass
# Стиль для кода (без изменений) # Стиль для кода (без изменений)
try: try:
code_style = styles.add_style('Code', WD_STYLE_TYPE.CHARACTER) code_style = styles.add_style('Code', WD_STYLE_TYPE.CHARACTER)
code_font = code_style.font code_font = code_style.font
code_font.name = 'Courier New' set_style_font(code_style, 'Courier New')
code_font.size = Pt(self.settings.font_size) code_font.size = Pt(self.settings.font_size)
code_font.color.rgb = RGBColor(*self.settings.text_color) code_font.color.rgb = RGBColor(*self.settings.text_color)
except: except ValueError: # стиль уже есть
pass pass
# Стиль для блоков кода # Стиль для блоков кода
try: try:
code_block_style = styles.add_style('Code Block', WD_STYLE_TYPE.PARAGRAPH) code_block_style = styles.add_style('Code Block', WD_STYLE_TYPE.PARAGRAPH)
code_block_font = code_block_style.font code_block_font = code_block_style.font
code_block_font.name = 'Courier New' set_style_font(code_block_style, 'Courier New')
code_block_font.size = Pt(self.settings.font_size) code_block_font.size = Pt(self.settings.font_size)
code_block_font.color.rgb = RGBColor(*self.settings.text_color) code_block_font.color.rgb = RGBColor(*self.settings.text_color)
@ -207,23 +245,25 @@ class MarkdownToDocxConverter:
code_block_paragraph.first_line_indent = Cm(0) # без отступа первой строки для кода code_block_paragraph.first_line_indent = Cm(0) # без отступа первой строки для кода
code_block_paragraph.space_before = Pt(6) code_block_paragraph.space_before = Pt(6)
code_block_paragraph.space_after = Pt(6) code_block_paragraph.space_after = Pt(6)
except: except ValueError: # стиль уже есть
pass pass
# Стиль для подписей к таблицам и рисункам # Стиль для подписей к таблицам и рисункам
try: # «Caption» уже есть во встроенном шаблоне (синий, 9 пт) — настраиваем его, а не создаём
caption_style = styles.add_style('Caption', WD_STYLE_TYPE.PARAGRAPH) caption_style = (styles['Caption'] if 'Caption' in [s.name for s in styles]
caption_font = caption_style.font else styles.add_style('Caption', WD_STYLE_TYPE.PARAGRAPH))
caption_font.name = self.settings.font_name caption_font = caption_style.font
caption_font.size = Pt(self.settings.font_size - 2) # меньше основного текста set_style_font(caption_style, self.settings.font_name)
caption_font.color.rgb = RGBColor(*self.settings.text_color) caption_font.size = Pt(self.settings.font_size - 2) # меньше основного текста
caption_font.bold = False
caption_paragraph = caption_style.paragraph_format caption_font.italic = False
caption_paragraph.alignment = WD_ALIGN_PARAGRAPH.CENTER caption_font.color.rgb = RGBColor(*self.settings.text_color)
caption_paragraph.space_before = Pt(6)
caption_paragraph.space_after = Pt(6) caption_paragraph = caption_style.paragraph_format
except: caption_paragraph.alignment = WD_ALIGN_PARAGRAPH.CENTER
pass caption_paragraph.first_line_indent = Cm(0)
caption_paragraph.space_before = Pt(6)
caption_paragraph.space_after = Pt(6)
def generate_heading_number(self, level: int) -> str: def generate_heading_number(self, level: int) -> str:
"""Генерация номера заголовка согласно настройкам автонумерации""" """Генерация номера заголовка согласно настройкам автонумерации"""
@ -250,11 +290,11 @@ class MarkdownToDocxConverter:
def parse_markdown_file(self, file_path: str): def parse_markdown_file(self, file_path: str):
"""Чтение и парсинг Markdown файла""" """Чтение и парсинг Markdown файла"""
try: try:
with open(file_path, 'r', encoding='utf-8') as file: with open(file_path, 'r', encoding='utf-8-sig') as file:
content = file.read() content = file.read()
return content return content
except Exception as e: except Exception as e:
raise Exception(f"Ошибка чтения файла: {e}") raise Exception(f"Ошибка чтения файла: {e}") from e
def add_text_run_with_color(self, paragraph, text, bold=False, italic=False, code_style=False): def add_text_run_with_color(self, paragraph, text, bold=False, italic=False, code_style=False):
"""Добавление текста с настройкой цвета""" """Добавление текста с настройкой цвета"""
@ -305,68 +345,74 @@ class MarkdownToDocxConverter:
# Обычный текст # Обычный текст
self.add_text_run_with_color(paragraph, part) self.add_text_run_with_color(paragraph, part)
LIST_ITEM = re.compile(r'^(\s*)([-*+]|\d+[.)])\s+(.*)$')
def process_list(self, lines: list, start_idx: int): def process_list(self, lines: list, start_idx: int):
"""Обработка списков с правильным форматированием по ГОСТ""" """Обработка списков с правильным форматированием по ГОСТ.
Маркер — тире, нумерация своя для каждого списка и уровня (раньше стиль List Bullet давал
второй маркер, вложенность терялась, а List Number продолжал счёт из предыдущего списка)"""
i = start_idx i = start_idx
list_items = [] counters = {}
while i < len(lines): while i < len(lines):
line = lines[i].strip() match = self.LIST_ITEM.match(lines[i].expandtabs(4))
if not match:
if re.match(r'^[-*+]\s', line): if lines[i].strip() == '' and i + 1 < len(lines) and self.LIST_ITEM.match(lines[i + 1].expandtabs(4)):
item_text = re.sub(r'^[-*+]\s', '', line) i += 1
list_items.append(('bullet', item_text, 0)) continue
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 break
i += 1
indent, marker, text = match.groups()
# Добавление элементов списка с настройками ГОСТ level = min(len(indent) // 2, 3)
for list_type, text, level in list_items: for deeper in [lvl for lvl in counters if lvl > level]:
paragraph = self.doc.add_paragraph() del counters[deeper]
paragraph.paragraph_format.left_indent = Cm(level * 0.75) # увеличенный отступ для вложенности
paragraph.paragraph_format.first_line_indent = Cm(self.settings.paragraph_indent) if marker[0].isdigit():
counters[level] = counters.get(level, 0) + 1
if list_type == 'bullet': prefix = f"{counters[level]}) "
paragraph.style = 'List Bullet'
# Используем тире вместо точек (согласно ГОСТ)
bullet_run = paragraph.runs[0] if paragraph.runs else paragraph.add_run()
bullet_run.text = "– " # длинное тире
else: else:
paragraph.style = 'List Number' counters.pop(level, None)
prefix = "– " # тире вместо точек (ГОСТ)
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)
self.add_text_run_with_color(paragraph, prefix)
self.process_text_formatting(text, paragraph) self.process_text_formatting(text, paragraph)
i += 1
return i - 1 return i - 1
TABLE_SEPARATOR = re.compile(r'^\|?\s*:?-+:?\s*(\|\s*:?-+:?\s*)*\|?$')
@staticmethod
def split_row(line: str) -> list:
"""Ячейки строки таблицы; внешние «|» необязательны"""
line = line.strip()
if line.startswith('|'):
line = line[1:]
if line.endswith('|'):
line = line[:-1]
return [cell.strip() for cell in line.split('|')]
def process_table(self, lines: list, start_idx: int): def process_table(self, lines: list, start_idx: int):
"""Обработка таблиц с подписями согласно ГОСТ""" """Обработка таблиц с подписями согласно ГОСТ"""
i = start_idx i = start_idx
table_lines = [] table_lines = []
# таблица заканчивается на пустой строке — иначе две таблицы подряд сливались в одну
while i < len(lines): while i < len(lines):
line = lines[i].strip() line = lines[i].strip()
if '|' in line: if '|' in line:
table_lines.append(line) table_lines.append(line)
elif line == '':
i += 1
continue
else: else:
break break
i += 1 i += 1
if len(table_lines) < 2: if len(table_lines) < 2 or not self.TABLE_SEPARATOR.match(table_lines[1]):
# не таблица, а строка с «|» — обычный абзац
paragraph = self.doc.add_paragraph()
self.process_text_formatting(lines[start_idx].strip(), paragraph)
return start_idx return start_idx
# Добавляем подпись к таблице (если настроено) # Добавляем подпись к таблице (если настроено)
@ -374,10 +420,11 @@ class MarkdownToDocxConverter:
self.table_counter += 1 self.table_counter += 1
caption_para = self.doc.add_paragraph() caption_para = self.doc.add_paragraph()
caption_para.style = 'Caption' caption_para.style = 'Caption'
caption_para.alignment = WD_ALIGN_PARAGRAPH.LEFT
caption_para.add_run(f"Таблица {self.table_counter}") caption_para.add_run(f"Таблица {self.table_counter}")
# Парсинг и создание таблицы # Парсинг и создание таблицы
headers = [cell.strip() for cell in table_lines[0].split('|')[1:-1]] headers = self.split_row(table_lines[0])
data_lines = table_lines[2:] if len(table_lines) > 2 else [] data_lines = table_lines[2:] if len(table_lines) > 2 else []
table = self.doc.add_table(rows=1, cols=len(headers)) table = self.doc.add_table(rows=1, cols=len(headers))
@ -395,7 +442,7 @@ class MarkdownToDocxConverter:
# Заполнение данных # Заполнение данных
for line in data_lines: for line in data_lines:
row_data = [cell.strip() for cell in line.split('|')[1:-1]] row_data = self.split_row(line)
row = table.add_row() row = table.add_row()
for idx, cell_data in enumerate(row_data): for idx, cell_data in enumerate(row_data):
if idx < len(row.cells): if idx < len(row.cells):
@ -404,11 +451,23 @@ class MarkdownToDocxConverter:
for paragraph in row.cells[idx].paragraphs: for paragraph in row.cells[idx].paragraphs:
paragraph.alignment = WD_ALIGN_PARAGRAPH.CENTER paragraph.alignment = WD_ALIGN_PARAGRAPH.CENTER
# в ячейках — без абзацного отступа и с одинарным интервалом, иначе текст смещён,
# а строки получаются высокими
for row in table.rows:
for cell in row.cells:
for paragraph in cell.paragraphs:
fmt = paragraph.paragraph_format
fmt.first_line_indent = Cm(0)
fmt.space_before = Pt(0)
fmt.space_after = Pt(0)
fmt.line_spacing = 1.0
# Подпись снизу (если настроено) # Подпись снизу (если настроено)
if self.settings.table_caption_position == "below": if self.settings.table_caption_position == "below":
self.table_counter += 1 self.table_counter += 1
caption_para = self.doc.add_paragraph() caption_para = self.doc.add_paragraph()
caption_para.style = 'Caption' caption_para.style = 'Caption'
caption_para.alignment = WD_ALIGN_PARAGRAPH.LEFT
caption_para.add_run(f"Таблица {self.table_counter}") caption_para.add_run(f"Таблица {self.table_counter}")
return i - 1 return i - 1
@ -451,8 +510,8 @@ class MarkdownToDocxConverter:
# Поиск элементов библиографии # Поиск элементов библиографии
while i < len(lines): while i < len(lines):
line = lines[i].strip() line = lines[i].strip()
if re.match(r'^\d+\.\s', line): if re.match(r'^(\d+[.)]|[-*+])\s', line):
bib_text = re.sub(r'^\d+\.\s', '', line) bib_text = re.sub(r'^(\d+[.)]|[-*+])\s', '', line)
bib_items.append(bib_text) bib_items.append(bib_text)
elif line == '': elif line == '':
i += 1 i += 1
@ -462,12 +521,7 @@ class MarkdownToDocxConverter:
i += 1 i += 1
if bib_items: if bib_items:
# Заголовок списка литературы # Элементы библиографии (заголовок уже добавлен в convert)
bib_heading = self.doc.add_paragraph()
bib_heading.style = 'Heading 1'
bib_heading.add_run("СПИСОК ЛИТЕРАТУРЫ")
# Элементы библиографии
for idx, item in enumerate(bib_items, 1): for idx, item in enumerate(bib_items, 1):
bib_para = self.doc.add_paragraph() bib_para = self.doc.add_paragraph()
bib_para.paragraph_format.first_line_indent = Cm(0) bib_para.paragraph_format.first_line_indent = Cm(0)
@ -511,37 +565,41 @@ class MarkdownToDocxConverter:
match = re.match(r'^(#{1,6})\s+(.+)', stripped_line) match = re.match(r'^(#{1,6})\s+(.+)', stripped_line)
if match: if match:
level = len(match.group(1)) level = len(match.group(1))
title = match.group(2) title = match.group(2).strip()
structural = bool(STRUCTURAL_HEADINGS.match(title.rstrip(':')))
# Разрыв страницы перед заголовком 2 уровня # Разрыв страницы перед заголовком 2 уровня
if level == 2: if level == 2:
self.doc.add_page_break() self.doc.add_page_break()
heading = self.doc.add_paragraph() heading = self.doc.add_paragraph()
heading.style = f'Heading {level}' heading.style = f'Heading {level}'
# Добавляем автонумерацию if structural:
heading_number = self.generate_heading_number(level) # структурные элементы (введение, заключение, список литературы...) по ГОСТ
full_title = heading_number + title # не нумеруются, пишутся прописными и по центру
heading.alignment = WD_ALIGN_PARAGRAPH.CENTER
self.process_text_formatting(full_title, heading) heading.paragraph_format.first_line_indent = Cm(0)
self.process_text_formatting(title.upper(), heading)
if BIBLIOGRAPHY_HEADING.match(title.rstrip(':')):
i = self.process_bibliography(lines, i + 1)
else:
# Добавляем автонумерацию
heading_number = self.generate_heading_number(level)
self.process_text_formatting(heading_number + title, heading)
# Блоки кода # Блоки кода
elif stripped_line.startswith('```'): elif stripped_line.startswith('```'):
i = self.process_code_block(lines, i) i = self.process_code_block(lines, i)
# Списки
elif self.LIST_ITEM.match(line.expandtabs(4)):
i = self.process_list(lines, i)
# Таблицы # Таблицы
elif '|' in stripped_line: elif '|' in stripped_line:
i = self.process_table(lines, i) 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('>'): elif stripped_line.startswith('>'):
quote_text = re.sub(r'^>\s?', '', stripped_line) quote_text = re.sub(r'^>\s?', '', stripped_line)
@ -576,47 +634,3 @@ class MarkdownToDocxConverter:
self.doc.save(output_path) self.doc.save(output_path)
return 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")
"""