From 848eb462b56e0b13ff4e8465dfa765dedf58cf7f Mon Sep 17 00:00:00 2001 From: Mikan <72257910+Mikan-DS@users.noreply.github.com> Date: Mon, 8 Dec 2025 16:57:25 +0300 Subject: [PATCH] initial MVP --- .idea/.gitignore | 8 ++ .idea/Markdown2Word.iml | 10 ++ .../inspectionProfiles/profiles_settings.xml | 6 ++ .idea/modules.xml | 8 ++ .idea/vcs.xml | 6 ++ main.py | 0 requirements.txt | 4 + resources/style_config.yaml | 25 +++++ src/__init__.py | 0 src/__main__.py | 16 ++++ src/core/__init__.py | 0 src/core/converter.py | 30 ++++++ src/core/renderer.py | 17 ++++ src/core/style_registry.py | 56 +++++++++++ src/handlers/__init__.py | 16 ++++ src/handlers/builtin.py | 84 ++++++++++++++++ tests/conftest.py | 3 + tests/fixtures/images/logo.png | Bin 0 -> 286 bytes tests/fixtures/sample.md | 19 ++++ tests/test_handlers.py | 90 ++++++++++++++++++ 20 files changed, 398 insertions(+) create mode 100644 .idea/.gitignore create mode 100644 .idea/Markdown2Word.iml create mode 100644 .idea/inspectionProfiles/profiles_settings.xml create mode 100644 .idea/modules.xml create mode 100644 .idea/vcs.xml create mode 100644 main.py create mode 100644 requirements.txt create mode 100644 resources/style_config.yaml create mode 100644 src/__init__.py create mode 100644 src/__main__.py create mode 100644 src/core/__init__.py create mode 100644 src/core/converter.py create mode 100644 src/core/renderer.py create mode 100644 src/core/style_registry.py create mode 100644 src/handlers/__init__.py create mode 100644 src/handlers/builtin.py create mode 100644 tests/conftest.py create mode 100644 tests/fixtures/images/logo.png create mode 100644 tests/fixtures/sample.md create mode 100644 tests/test_handlers.py diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..13566b8 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,8 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Editor-based HTTP Client requests +/httpRequests/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml diff --git a/.idea/Markdown2Word.iml b/.idea/Markdown2Word.iml new file mode 100644 index 0000000..00ebb6a --- /dev/null +++ b/.idea/Markdown2Word.iml @@ -0,0 +1,10 @@ + + + + + + + + + + \ No newline at end of file diff --git a/.idea/inspectionProfiles/profiles_settings.xml b/.idea/inspectionProfiles/profiles_settings.xml new file mode 100644 index 0000000..105ce2d --- /dev/null +++ b/.idea/inspectionProfiles/profiles_settings.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 0000000..39bf1df --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..94a25f7 --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/main.py b/main.py new file mode 100644 index 0000000..e69de29 diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..6f24a94 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,4 @@ +mistune>=3.0.0 +python-docx +typer[all] +PyYAML \ No newline at end of file diff --git a/resources/style_config.yaml b/resources/style_config.yaml new file mode 100644 index 0000000..b8e688c --- /dev/null +++ b/resources/style_config.yaml @@ -0,0 +1,25 @@ +styles: + Heading 1: + font: + name: "Times New Roman" + size: 16 + bold: true + paragraph: + space_before: 12 + space_after: 6 + Normal: + font: + name: "Times New Roman" + size: 12 + Caption: + font: + name: "Arial" + size: 10 + italic: true + paragraph: + alignment: center + space_before: 4 + space_after: 8 + +colors: + LinkColor: "0000FF" \ No newline at end of file diff --git a/src/__init__.py b/src/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/__main__.py b/src/__main__.py new file mode 100644 index 0000000..64ba54e --- /dev/null +++ b/src/__main__.py @@ -0,0 +1,16 @@ +import typer +from pathlib import Path +from .core.converter import process_document + +app = typer.Typer() + +@app.command() +def convert( + input_dir: Path = typer.Option(..., "--input", "-i", help="Папка с исходниками"), + output_dir: Path = typer.Option(..., "--output", "-o", help="Папка для результата"), + style_config: Path = typer.Option("resources/style_config.yaml", "--style", "-s", help="Файл стилей") +): + process_document(input_dir, output_dir, style_config) + +if __name__ == "__main__": + app() \ No newline at end of file diff --git a/src/core/__init__.py b/src/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/core/converter.py b/src/core/converter.py new file mode 100644 index 0000000..e08c1de --- /dev/null +++ b/src/core/converter.py @@ -0,0 +1,30 @@ +from pathlib import Path +from docx import Document +from .style_registry import StyleRegistry +from .renderer import render_markdown_to_docx + +def process_document(input_dir: Path, output_dir: Path, style_config_path: Path): + style_reg = StyleRegistry(style_config_path) + + for doc_dir in input_dir.iterdir(): + if not doc_dir.is_dir(): + continue + doc_name = doc_dir.name + md_path = doc_dir / "document.md" + images_dir = doc_dir / "images" + + if not md_path.exists(): + print(f"⚠️ Пропущено: {doc_name} — нет document.md") + continue + + with open(md_path, encoding="utf-8") as f: + md_content = f.read() + + doc = Document() + style_reg.ensure_styles_in_doc(doc) # добавляем стили + render_markdown_to_docx(doc, md_content, images_dir, style_reg) + + output_path = output_dir / f"{doc_name}.docx" + output_path.parent.mkdir(parents=True, exist_ok=True) + doc.save(output_path) + print(f"✅ {doc_name} → {output_path}") \ No newline at end of file diff --git a/src/core/renderer.py b/src/core/renderer.py new file mode 100644 index 0000000..53d674a --- /dev/null +++ b/src/core/renderer.py @@ -0,0 +1,17 @@ +import mistune +from .style_registry import StyleRegistry +from ..handlers import get_handler + +def render_markdown_to_docx(doc, md_content: str, images_dir, style_reg: StyleRegistry): + markdown = mistune.create_markdown(renderer=None) + ast = markdown(md_content) + + def walk(node): + handler = get_handler(node["type"]) + if handler: + handler(node, doc, images_dir, style_reg) + elif node.get("children"): + for child in node["children"]: + walk(child) + + walk(ast) \ No newline at end of file diff --git a/src/core/style_registry.py b/src/core/style_registry.py new file mode 100644 index 0000000..fd6d440 --- /dev/null +++ b/src/core/style_registry.py @@ -0,0 +1,56 @@ +import yaml +from docx import Document +from docx.enum.style import WD_STYLE_TYPE +from docx.shared import Pt, RGBColor +from docx.enum.text import WD_LINE_SPACING, WD_ALIGN_PARAGRAPH +from docx.styles.style import ParagraphStyle + +class StyleRegistry: + def __init__(self, config_path): + with open(config_path, encoding="utf-8") as f: + self.config = yaml.safe_load(f) + + def ensure_styles_in_doc(self, doc: Document): + """Создаёт недостающие стили в документе, если их нет.""" + for name, cfg in self.config["styles"].items(): + try: + doc.styles[name] + except KeyError: + # Создаём стиль + style = doc.styles.add_style(name, WD_STYLE_TYPE.PARAGRAPH) + self.apply_style(style, name) + + def apply_style(self, style, style_name: str): + cfg = self.config["styles"].get(style_name) + if not cfg: + return + + font_cfg = cfg.get("font", {}) + para_cfg = cfg.get("paragraph", {}) + + if font_cfg: + font = style.font + font.name = font_cfg.get("name", font.name) + if "size" in font_cfg: + font.size = Pt(font_cfg["size"]) + font.bold = font_cfg.get("bold", font.bold) + font.italic = font_cfg.get("italic", font.italic) + + if para_cfg: + pf = style.paragraph_format + if "space_before" in para_cfg: + pf.space_before = Pt(para_cfg["space_before"]) + if "space_after" in para_cfg: + pf.space_after = Pt(para_cfg["space_after"]) + if "line_spacing" in para_cfg: + pf.line_spacing = para_cfg["line_spacing"] + pf.line_spacing_rule = WD_LINE_SPACING.MULTIPLE + if "alignment" in para_cfg: + alignment_map = {"center": WD_ALIGN_PARAGRAPH.CENTER} + align = alignment_map.get(para_cfg["alignment"]) + if align: + pf.alignment = align + + def get_color(self, key: str): + hex_color = self.config.get("colors", {}).get(key, "000000") + return RGBColor.from_string(hex_color) \ No newline at end of file diff --git a/src/handlers/__init__.py b/src/handlers/__init__.py new file mode 100644 index 0000000..3f2cd29 --- /dev/null +++ b/src/handlers/__init__.py @@ -0,0 +1,16 @@ +from typing import Dict, Callable, Any +from docx.document import Document +from pathlib import Path + +Handler = Callable[[dict, Document, Path, 'StyleRegistry'], None] + +HANDLER_REGISTRY: Dict[str, Handler] = {} + +def register_handler(node_type: str): + def decorator(fn: Handler): + HANDLER_REGISTRY[node_type] = fn + return fn + return decorator + +def get_handler(node_type: str) -> Handler: + return HANDLER_REGISTRY.get(node_type) \ No newline at end of file diff --git a/src/handlers/builtin.py b/src/handlers/builtin.py new file mode 100644 index 0000000..5952d93 --- /dev/null +++ b/src/handlers/builtin.py @@ -0,0 +1,84 @@ +import logging + +logger = logging.getLogger(__name__) + +from pathlib import Path + +from src.handlers import register_handler +from docx.shared import Inches +from docx.enum.text import WD_ALIGN_PARAGRAPH + +@register_handler("heading") +def handle_heading(node: dict, doc, images_dir: Path, style_reg): + level = min(node["level"], 3) + text = node["children"][0]["raw"] if node["children"] else "" + doc.add_heading(text, level=level) + p = doc.paragraphs[-1] + style_reg.apply_style(p.style, f"Heading {level}") + +def _render_inline_tokens(p, tokens, doc, images_dir, style_reg): + for token in tokens: + if token["type"] == "text": + p.add_run(token["raw"]) + elif token["type"] == "strong": + run = p.add_run() + run.bold = True + run.text = token["children"][0]["raw"] if token["children"] else "" + elif token["type"] == "emphasis": + run = p.add_run() + run.italic = True + run.text = token["children"][0]["raw"] if token["children"] else "" + elif token["type"] == "link": + run = p.add_run() + run.text = token["children"][0]["raw"] if token["children"] else "" + run.font.color.rgb = style_reg.get_color("LinkColor") + elif token["type"] == "image": + # Если image внутри параграфа — вызываем обработчик + handle_image(token, doc, images_dir, style_reg) + +@register_handler("paragraph") +def handle_paragraph(node: dict, doc, images_dir: Path, style_reg): + p = doc.add_paragraph() + _render_inline_tokens(p, node["children"], doc, images_dir, style_reg) + style_reg.apply_style(p.style, "Normal") + +@register_handler("image") +def handle_image(node: dict, doc, images_dir: Path, style_reg): + src = node["src"] + alt = node.get("alt", "") + img_path = images_dir / src + if img_path.exists(): + logger.debug(f"Обработка изображения: {img_path}, exists: {img_path.exists()}") + p = doc.add_paragraph() + run = p.add_run() + logger.debug(f"Добавлен параграф: {len(doc.paragraphs)} до add_picture") + try: + run.add_picture(str(img_path), width=Inches(5)) + logger.debug("add_picture успешно выполнен") + p.alignment = WD_ALIGN_PARAGRAPH.CENTER + except Exception as e: + logger.error(f"Ошибка при добавлении изображения {img_path}: {e}") + doc.add_paragraph(f"[Изображение не загружено: {src}]") + return + + logger.debug(f"После add_picture: {len(doc.paragraphs)} параграфов") + if alt: + try: + cap_p = doc.add_paragraph(alt, style="Caption") + logger.debug(f"Добавлена подпись: {alt}, всего параграфов: {len(doc.paragraphs)}") + except KeyError: + cap_p = doc.add_paragraph(alt) + style_reg.apply_style(cap_p.style, "Caption") + logger.debug(f"Добавлена подпись без стиля: {alt}") + else: + doc.add_paragraph(f"[Изображение не найдено: {src}]") + + +@register_handler("list") +def handle_list(node: dict, doc, images_dir: Path, style_reg): + ordered = node.get("ordered", False) + for item in node["children"]: + p = doc.add_paragraph(style="List Number" if ordered else "List Bullet") + for child in item["children"]: + if child["type"] == "text": + p.add_run(child["raw"]) \ No newline at end of file diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..b85fb43 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,3 @@ +import logging + +logging.basicConfig(level=logging.DEBUG) \ No newline at end of file diff --git a/tests/fixtures/images/logo.png b/tests/fixtures/images/logo.png new file mode 100644 index 0000000000000000000000000000000000000000..e4d133fafc6ad048bf060255ab7e45b65f00e1a8 GIT binary patch literal 286 zcmeAS@N?(olHy`uVBq!ia0vp^DIml7PxhL`Uy6GLE6=4cP8(w=;+ZD%n{LU14NuY-rJYD@<);T3K0RSkeOf>)i literal 0 HcmV?d00001 diff --git a/tests/fixtures/sample.md b/tests/fixtures/sample.md new file mode 100644 index 0000000..d4305f5 --- /dev/null +++ b/tests/fixtures/sample.md @@ -0,0 +1,19 @@ +# Заголовок 1 + +Это обычный параграф. + +**Жирный текст**, *курсив* и [ссылка](https://example.com). + +## Заголовок 2 + +- Элемент списка 1 +- Элемент списка 2 + +![Логотип](images/logo.png) + +### Заголовок 3 + +Нумерованный список: + +1. Первый +2. Второй \ No newline at end of file diff --git a/tests/test_handlers.py b/tests/test_handlers.py new file mode 100644 index 0000000..3ba360a --- /dev/null +++ b/tests/test_handlers.py @@ -0,0 +1,90 @@ +import pytest +from pathlib import Path +from docx import Document +from src.handlers.builtin import handle_heading, handle_paragraph, handle_image, handle_list +from src.core.style_registry import StyleRegistry + +# Тесты для обработчиков +def test_handle_heading(): + doc = Document() + style_reg = StyleRegistry(Path("resources/style_config.yaml")) + node = {"type": "heading", "level": 1, "children": [{"type": "text", "raw": "Заголовок 1"}]} + handle_heading(node, doc, Path("."), style_reg) + assert len(doc.paragraphs) == 1 + assert doc.paragraphs[0].style.name == "Heading 1" + +def test_handle_paragraph(): + doc = Document() + style_reg = StyleRegistry(Path("resources/style_config.yaml")) + node = {"type": "paragraph", "children": [{"type": "text", "raw": "Текст параграфа"}]} + handle_paragraph(node, doc, Path("."), style_reg) + assert len(doc.paragraphs) == 1 + assert doc.paragraphs[0].text == "Текст параграфа" + +def test_handle_image(): + from PIL import Image + doc = Document() + style_reg = StyleRegistry(Path("resources/style_config.yaml")) + style_reg.ensure_styles_in_doc(doc) + node = {"type": "image", "src": "images/logo.png", "alt": "Логотип"} # ✅ src = images/logo.png + + # Создаем реальное изображение + img_path = Path("tests/fixtures/images/logo.png") + img_path.parent.mkdir(exist_ok=True) + img = Image.new("RGB", (100, 100), color="red") + img.save(img_path) + + handle_image(node, doc, Path("tests/fixtures"), style_reg) # images_dir = tests/fixtures + + # После вызова должно быть 2 параграфа: картинка и подпись + assert len(doc.paragraphs) == 2 + assert doc.paragraphs[1].text == "Логотип" # подпись + + +def test_debug_handle_image(): + from PIL import Image + doc = Document() + style_reg = StyleRegistry(Path("resources/style_config.yaml")) + style_reg.ensure_styles_in_doc(doc) + + # Проверим, есть ли стиль "Caption" в документе + print("Доступные стили:", [s.name for s in doc.styles if s.type.name == 'PARAGRAPH']) + assert "Caption" in [s.name for s in doc.styles if s.type.name == 'PARAGRAPH'] + + node = {"type": "image", "src": "logo.png", "alt": "Логотип"} + img_path = Path("tests/fixtures/images/logo.png") + img_path.parent.mkdir(exist_ok=True) + img = Image.new("RGB", (100, 100), color="red") + img.save(img_path) + + handle_image(node, doc, Path("tests/fixtures"), style_reg) + + print("Количество параграфов:", len(doc.paragraphs)) + for i, p in enumerate(doc.paragraphs): + print(f"Параграф {i}: '{p.text}', стиль: {p.style.name if p.style else 'None'}") + +def test_handle_paragraph_with_inline(): + doc = Document() + style_reg = StyleRegistry(Path("resources/style_config.yaml")) + node = { + "type": "paragraph", + "children": [ + {"type": "text", "raw": "Текст "}, + {"type": "strong", "children": [{"type": "text", "raw": "жирный"}]}, + {"type": "text", "raw": " и "}, + {"type": "emphasis", "children": [{"type": "text", "raw": "курсив"}]}, + {"type": "text", "raw": "."}, + ] + } + handle_paragraph(node, doc, Path("."), style_reg) + + p = doc.paragraphs[0] + runs = p.runs + assert len(runs) == 5 + assert runs[0].text == "Текст " + assert runs[1].text == "жирный" + assert runs[1].bold is True + assert runs[2].text == " и " + assert runs[3].text == "курсив" + assert runs[3].italic is True + assert runs[4].text == "." \ No newline at end of file