import re import json import io import base64 import requests from pathlib import Path from typing import List, Dict, Any, Optional, Tuple import markdown from markdown.extensions import codehilite, fenced_code, tables, toc from docx import Document from docx.shared import Pt, RGBColor, Inches from docx.enum.text import WD_PARAGRAPH_ALIGNMENT from docx.enum.style import WD_STYLE_TYPE from docx.oxml import OxmlElement from docx.oxml.ns import qn from bs4 import BeautifulSoup, NavigableString class MarkdownToDocxConverter: """Конвертер Markdown в DOCX с сохранением структуры и форматирования""" def __init__(self): self.doc = None # Цветовая схема для подсветки кода self.code_colors = { 'keyword': RGBColor(0, 0, 255), # Синий для ключевых слов 'string': RGBColor(0, 128, 0), # Зеленый для строк 'comment': RGBColor(128, 128, 128), # Серый для комментариев 'number': RGBColor(255, 0, 0), # Красный для чисел 'function': RGBColor(128, 0, 128), # Фиолетовый для функций 'default': RGBColor(0, 0, 0) # Черный по умолчанию } def create_styles(self): """Создание пользовательских стилей для документа""" # Стиль для блоков кода code_style = self.doc.styles.add_style('CodeBlock', WD_STYLE_TYPE.PARAGRAPH) code_style.font.name = 'Consolas' code_style.font.size = Pt(9) code_style.paragraph_format.space_before = Pt(6) code_style.paragraph_format.space_after = Pt(6) code_style.paragraph_format.left_indent = Inches(0.3) # Добавляем серый фон для блоков кода shading = OxmlElement('w:shd') shading.set(qn('w:val'), 'clear') shading.set(qn('w:color'), 'auto') shading.set(qn('w:fill'), 'F0F0F0') code_style.element.get_or_add_pPr().append(shading) # Стиль для inline кода inline_code_style = self.doc.styles.add_style('InlineCode', WD_STYLE_TYPE.CHARACTER) inline_code_style.font.name = 'Consolas' inline_code_style.font.size = Pt(9) inline_code_style.font.color.rgb = RGBColor(255, 0, 0) # Стили для заголовков for i in range(1, 7): heading_style = self.doc.styles[f'Heading {i}'] heading_style.font.color.rgb = RGBColor(0, 0, 0) heading_style.font.bold = True heading_style.font.size = Pt(26 - i * 3) def parse_code_block(self, code_text: str, language: str = '') -> List[Tuple[str, str]]: """Простая подсветка синтаксиса для кода""" tokens = [] if language.lower() in ['json', 'javascript', 'js']: # Простая токенизация для JSON/JavaScript lines = code_text.split('\n') for line in lines: # Комментарии if line.strip().startswith('//'): tokens.append((line + '\n', 'comment')) continue # Строки в кавычках parts = re.split(r'("(?:[^"\\]|\\.)*")', line) for i, part in enumerate(parts): if i % 2 == 1: # Строка в кавычках tokens.append((part, 'string')) else: # Числа sub_parts = re.split(r'(\b\d+\.?\d*\b)', part) for j, sub_part in enumerate(sub_parts): if j % 2 == 1: # Число tokens.append((sub_part, 'number')) else: # Ключевые слова keywords = ['GET', 'POST', 'PUT', 'DELETE', 'Bearer', 'Authorization'] for keyword in keywords: if keyword in sub_part: sub_part = sub_part.replace(keyword, f'\x00{keyword}\x01') final_parts = re.split(r'\x00(.*?)\x01', sub_part) for k, final_part in enumerate(final_parts): if k % 2 == 1: # Ключевое слово tokens.append((final_part, 'keyword')) else: tokens.append((final_part, 'default')) tokens.append(('\n', 'default')) elif language.lower() == 'http': lines = code_text.split('\n') for line in lines: if line.startswith(('GET', 'POST', 'PUT', 'DELETE', 'PATCH')): parts = line.split(' ', 2) if len(parts) >= 1: tokens.append((parts[0] + ' ', 'keyword')) if len(parts) >= 2: tokens.append((parts[1], 'string')) if len(parts) >= 3: tokens.append((' ' + parts[2], 'default')) elif ':' in line: key, value = line.split(':', 1) tokens.append((key + ':', 'function')) tokens.append((value, 'string')) else: tokens.append((line, 'default')) tokens.append(('\n', 'default')) else: # Для других языков просто возвращаем текст как есть tokens.append((code_text, 'default')) return tokens def add_code_block(self, code_text: str, language: str = ''): """Добавление блока кода с подсветкой синтаксиса""" # Создаем параграф с кодом p = self.doc.add_paragraph() p.style = 'CodeBlock' # Парсим и добавляем токены с цветами tokens = self.parse_code_block(code_text.strip(), language) for token_text, token_type in tokens: if token_text: run = p.add_run(token_text) run.font.name = 'Consolas' run.font.size = Pt(9) run.font.color.rgb = self.code_colors.get(token_type, self.code_colors['default']) def process_inline_code(self, paragraph, text: str): """Обработка inline кода в тексте""" parts = re.split(r'`([^`]+)`', text) for i, part in enumerate(parts): if i % 2 == 0: # Обычный текст if part: paragraph.add_run(part) else: # Код run = paragraph.add_run(part) run.font.name = 'Consolas' run.font.size = Pt(9) run.font.color.rgb = RGBColor(255, 0, 0) # Добавляем серый фон shading = OxmlElement('w:shd') shading.set(qn('w:val'), 'clear') shading.set(qn('w:color'), 'auto') shading.set(qn('w:fill'), 'E0E0E0') run._element.get_or_add_rPr().append(shading) def process_html_element(self, element, parent_paragraph=None): """Рекурсивная обработка HTML элементов""" if isinstance(element, NavigableString): text = str(element) if parent_paragraph and text.strip(): if '`' in text: self.process_inline_code(parent_paragraph, text) else: parent_paragraph.add_run(text) return if element.name == 'h1': p = self.doc.add_heading(element.get_text(), level=1) elif element.name == 'h2': p = self.doc.add_heading(element.get_text(), level=2) elif element.name == 'h3': p = self.doc.add_heading(element.get_text(), level=3) elif element.name == 'h4': p = self.doc.add_heading(element.get_text(), level=4) elif element.name == 'h5': p = self.doc.add_heading(element.get_text(), level=5) elif element.name == 'h6': p = self.doc.add_heading(element.get_text(), level=6) elif element.name == 'p': # Проверяем, содержит ли параграф только изображение img_children = element.find_all('img') if len(img_children) == 1 and not element.get_text(strip=True): self.process_html_element(img_children[0]) else: p = self.doc.add_paragraph() for child in element.children: self.process_html_element(child, p) elif element.name == 'img': self.add_image(element.get('src')) elif element.name == 'a': self.add_hyperlink(parent_paragraph, element.get_text(), element.get('href')) elif element.name == 'pre': code_element = element.find('code') if code_element: classes = code_element.get('class', []) code_text = code_element.get_text() # Check if it's a mermaid diagram if 'mermaid' in classes or 'language-mermaid' in classes: self.add_mermaid_diagram(code_text) else: # It's a regular code block, find the language language = '' for cls in classes: if cls.startswith('language-'): language = cls.replace('language-', '') break self.add_code_block(code_text, language) else: self.add_code_block(element.get_text()) elif element.name == 'code' and parent_paragraph: # Inline код run = parent_paragraph.add_run(element.get_text()) run.font.name = 'Consolas' run.font.size = Pt(9) run.font.color.rgb = RGBColor(255, 0, 0) elif element.name == 'ul': for li in element.find_all('li', recursive=False): p = self.doc.add_paragraph(style='List Bullet') for child in li.children: self.process_html_element(child, p) elif element.name == 'ol': for i, li in enumerate(element.find_all('li', recursive=False), 1): p = self.doc.add_paragraph(style='List Number') for child in li.children: self.process_html_element(child, p) elif element.name == 'strong' or element.name == 'b': if parent_paragraph: run = parent_paragraph.add_run(element.get_text()) run.bold = True elif element.name == 'em' or element.name == 'i': if parent_paragraph: run = parent_paragraph.add_run(element.get_text()) run.italic = True elif element.name == 'table': self.add_table(element) elif element.name == 'hr': # Горизонтальная линия p = self.doc.add_paragraph() p.add_run('_' * 50).font.color.rgb = RGBColor(128, 128, 128) else: # Для остальных элементов рекурсивно обрабатываем детей for child in element.children: self.process_html_element(child, parent_paragraph) def add_image(self, src: str): """Скачивание и вставка изображения по URL""" if not src: return try: response = requests.get(src, stream=True) response.raise_for_status() image_stream = io.BytesIO(response.content) self.doc.add_picture(image_stream, width=Inches(6)) except requests.exceptions.RequestException as e: print(f"Ошибка при скачивании изображения {src}: {e}") self.doc.add_paragraph(f"[Изображение не найдено: {src}]", style='Body Text') def add_mermaid_diagram(self, code: str): """Рендеринг и вставка диаграммы Mermaid через mermaid.ink""" try: # Кодируем код диаграммы в URL-безопасный base64 graphbytes = code.encode("utf-8") base64_bytes = base64.urlsafe_b64encode(graphbytes) base64_string = base64_bytes.decode("ascii") # Формируем URL для запроса url = f"https://mermaid.ink/img/{base64_string}" # Выполняем запрос и получаем изображение response = requests.get(url) response.raise_for_status() # Проверка на ошибки HTTP # Вставляем изображение в документ image_stream = io.BytesIO(response.content) self.doc.add_picture(image_stream, width=Inches(6)) except requests.exceptions.RequestException as e: print(f"Ошибка при рендеринге диаграммы Mermaid: {e}") self.doc.add_paragraph(f"[Ошибка рендеринга диаграммы: {e}]", style='Body Text') except Exception as e: print(f"Неожиданная ошибка при обработке Mermaid: {e}") self.doc.add_paragraph(f"[Неожиданная ошибка: {e}]", style='Body Text') def add_table(self, table_element): """Добавление таблицы в документ""" rows = table_element.find_all('tr') if not rows: return # Считаем количество столбцов cols = max(len(row.find_all(['td', 'th'])) for row in rows) # Создаем таблицу table = self.doc.add_table(rows=0, cols=cols) table.style = 'Table Grid' # Добавляем строки for row_element in rows: cells = row_element.find_all(['td', 'th']) row = table.add_row() for i, cell_element in enumerate(cells): if i < cols: cell = row.cells[i] # Очищаем ячейку cell.text = cell_element.get_text().strip() # Если это заголовок, делаем жирным if cell_element.name == 'th': for paragraph in cell.paragraphs: for run in paragraph.runs: run.bold = True def add_hyperlink(self, paragraph, text, url): """Добавление гиперссылки""" part = paragraph.part r_id = part.relate_to(url, 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/hyperlink', is_external=True) hyperlink = OxmlElement('w:hyperlink') hyperlink.set(qn('r:id'), r_id) new_run = OxmlElement('w:r') rPr = OxmlElement('w:rPr') # Стиль для гиперссылки style = OxmlElement('w:rStyle') style.set(qn('w:val'), 'Hyperlink') rPr.append(style) new_run.append(rPr) new_run.text = text hyperlink.append(new_run) paragraph._p.append(hyperlink) def convert(self, markdown_text: str, output_path: str): """Конвертация Markdown текста в DOCX файл""" # 1. Извлекаем диаграммы Mermaid и заменяем их плейсхолдерами mermaid_diagrams = {} def replace_mermaid(match): key = f"%%MERMAID_DIAGRAM_{len(mermaid_diagrams)}%%" # Сохраняем только код диаграммы mermaid_diagrams[key] = match.group(1).strip() # Возвращаем плейсхолдер в виде параграфа, чтобы он не был удален return f"\n

{key}

\n" # Регулярное выражение для поиска блоков ```mermaid ... ``` markdown_text = re.sub(r'```mermaid\n(.*?)\n```', replace_mermaid, markdown_text, flags=re.DOTALL) # 2. Создаем новый документ и стили self.doc = Document() self.create_styles() # 3. Конвертируем оставшийся Markdown в HTML md = markdown.Markdown(extensions=[ 'fenced_code', 'codehilite', 'tables', 'toc', 'nl2br', 'sane_lists' ]) html = md.convert(markdown_text) # 4. Парсим HTML и обрабатываем элементы soup = BeautifulSoup(html, 'html.parser') for element in soup.children: # Проверяем, содержит ли элемент плейсхолдер Mermaid if isinstance(element, NavigableString): # Пропускаем пустые строки if not str(element).strip(): continue text_content = element.get_text().strip() if "%%MERMAID_DIAGRAM_" in text_content: key = text_content if key in mermaid_diagrams: self.add_mermaid_diagram(mermaid_diagrams[key]) else: # Если плейсхолдер найден, но нет диаграммы, просто пропускаем continue else: self.process_html_element(element) # 5. Сохраняем документ self.doc.save(output_path) print(f"Документ успешно сохранен: {output_path}") # Пример использования if __name__ == "__main__": # Пример Markdown текста с API документацией sample_markdown = """# API Documentation ## Ресурсы ### GET /api/resources/presets Получить список доступных пресетов для создания проектов. **Запрос:** ```http GET /api/resources/presets Authorization: Bearer YOUR_API_KEY ``` **Ответ:** ```json { "status": "ok", "data": [ "news_template.json", "education_template.json", "entertainment_template.json" ] } ``` ### POST /api/resources/create Создать новый ресурс. **Параметры запроса:** - `name` (string, обязательный) - название ресурса - `type` (string, обязательный) - тип ресурса - `preset` (string, опциональный) - имя пресета **Пример запроса:** ```javascript const response = await fetch('/api/resources/create', { method: 'POST', headers: { 'Authorization': 'Bearer YOUR_API_KEY', 'Content-Type': 'application/json' }, body: JSON.stringify({ name: 'My Resource', type: 'document', preset: 'news_template.json' }) }); ``` ## Коды ответов | Код | Описание | |-----|----------| | 200 | Успешный запрос | | 401 | Неавторизован | | 404 | Ресурс не найден | | 500 | Внутренняя ошибка сервера | ## Примечания - Все запросы должны содержать заголовок `Authorization` - Ответы возвращаются в формате JSON - Используйте `UTF-8` кодировку для всех запросов """ # Создаем конвертер и конвертируем converter = MarkdownToDocxConverter() # converter.convert(sample_markdown, "USER_DOCUMENTATION.md") # Можно также конвертировать из файла try: with open('USER_DOCUMENTATION.md', 'r', encoding='utf-8') as f: markdown_content = f.read() converter.convert(markdown_content, 'output.docx') except UnicodeDecodeError: print("Error: Unable to decode file with UTF-8 encoding. Trying with different encoding...") try: with open('USER_DOCUMENTATION.md', 'r', encoding='latin-1') as f: markdown_content = f.read() converter.convert(markdown_content, 'output.docx') except Exception as e: print(f"Error reading file: {e}") except FileNotFoundError: print("Error: USER_DOCUMENTATION.md file not found") except Exception as e: print(f"Error: {e}")