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}") |