commit 848eb462b56e0b13ff4e8465dfa765dedf58cf7f
Author: Mikan <72257910+Mikan-DS@users.noreply.github.com>
Date: Mon Dec 8 16:57:25 2025 +0300
initial MVP
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 0000000..e4d133f
Binary files /dev/null and b/tests/fixtures/images/logo.png differ
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
+
+
+
+### Заголовок 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