markdown_to_docx / converter.py
daswer123's picture
Upload converter.py
b89a2c9 verified
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}")