Spaces:
Sleeping
Sleeping
| 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<p>{key}</p>\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}") |