markdown_to_docx / converter.py
daswer123's picture
Update converter.py
651a7ed verified
raw
history blame
17.4 kB
import re
import json
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.styles_created = False
# Цветовая схема для подсветки кода
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):
"""Создание пользовательских стилей для документа"""
if self.styles_created:
return
# Стиль для блоков кода
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)
self.styles_created = True
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':
p = self.doc.add_paragraph()
for child in element.children:
self.process_html_element(child, p)
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', [])
language = ''
for cls in classes:
if cls.startswith('language-'):
language = cls.replace('language-', '')
break
code_text = code_element.get_text()
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_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 файл"""
# Создаем новый документ
self.doc = Document()
self.create_styles()
# Конвертируем Markdown в HTML
md = markdown.Markdown(extensions=[
'fenced_code',
'codehilite',
'tables',
'toc',
'nl2br',
'sane_lists'
])
html = md.convert(markdown_text)
# Парсим HTML с BeautifulSoup
soup = BeautifulSoup(html, 'html.parser')
# Обрабатываем каждый элемент
for element in soup.children:
self.process_html_element(element)
# Сохраняем документ
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}")