initial MVP
This commit is contained in:
8
.idea/.gitignore
generated
vendored
Normal file
8
.idea/.gitignore
generated
vendored
Normal file
@@ -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
|
||||||
10
.idea/Markdown2Word.iml
generated
Normal file
10
.idea/Markdown2Word.iml
generated
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<module type="PYTHON_MODULE" version="4">
|
||||||
|
<component name="NewModuleRootManager">
|
||||||
|
<content url="file://$MODULE_DIR$">
|
||||||
|
<excludeFolder url="file://$MODULE_DIR$/.venv" />
|
||||||
|
</content>
|
||||||
|
<orderEntry type="jdk" jdkName="Python 3.12 (Markdown2Word)" jdkType="Python SDK" />
|
||||||
|
<orderEntry type="sourceFolder" forTests="false" />
|
||||||
|
</component>
|
||||||
|
</module>
|
||||||
6
.idea/inspectionProfiles/profiles_settings.xml
generated
Normal file
6
.idea/inspectionProfiles/profiles_settings.xml
generated
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
<component name="InspectionProjectProfileManager">
|
||||||
|
<settings>
|
||||||
|
<option name="USE_PROJECT_PROFILE" value="false" />
|
||||||
|
<version value="1.0" />
|
||||||
|
</settings>
|
||||||
|
</component>
|
||||||
8
.idea/modules.xml
generated
Normal file
8
.idea/modules.xml
generated
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="ProjectModuleManager">
|
||||||
|
<modules>
|
||||||
|
<module fileurl="file://$PROJECT_DIR$/.idea/Markdown2Word.iml" filepath="$PROJECT_DIR$/.idea/Markdown2Word.iml" />
|
||||||
|
</modules>
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
6
.idea/vcs.xml
generated
Normal file
6
.idea/vcs.xml
generated
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="VcsDirectoryMappings">
|
||||||
|
<mapping directory="$PROJECT_DIR$" vcs="Git" />
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
4
requirements.txt
Normal file
4
requirements.txt
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
mistune>=3.0.0
|
||||||
|
python-docx
|
||||||
|
typer[all]
|
||||||
|
PyYAML
|
||||||
25
resources/style_config.yaml
Normal file
25
resources/style_config.yaml
Normal file
@@ -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"
|
||||||
0
src/__init__.py
Normal file
0
src/__init__.py
Normal file
16
src/__main__.py
Normal file
16
src/__main__.py
Normal file
@@ -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()
|
||||||
0
src/core/__init__.py
Normal file
0
src/core/__init__.py
Normal file
30
src/core/converter.py
Normal file
30
src/core/converter.py
Normal file
@@ -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}")
|
||||||
17
src/core/renderer.py
Normal file
17
src/core/renderer.py
Normal file
@@ -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)
|
||||||
56
src/core/style_registry.py
Normal file
56
src/core/style_registry.py
Normal file
@@ -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)
|
||||||
16
src/handlers/__init__.py
Normal file
16
src/handlers/__init__.py
Normal file
@@ -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)
|
||||||
84
src/handlers/builtin.py
Normal file
84
src/handlers/builtin.py
Normal file
@@ -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"])
|
||||||
3
tests/conftest.py
Normal file
3
tests/conftest.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
import logging
|
||||||
|
|
||||||
|
logging.basicConfig(level=logging.DEBUG)
|
||||||
BIN
tests/fixtures/images/logo.png
vendored
Normal file
BIN
tests/fixtures/images/logo.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 286 B |
19
tests/fixtures/sample.md
vendored
Normal file
19
tests/fixtures/sample.md
vendored
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
# Заголовок 1
|
||||||
|
|
||||||
|
Это обычный параграф.
|
||||||
|
|
||||||
|
**Жирный текст**, *курсив* и [ссылка](https://example.com).
|
||||||
|
|
||||||
|
## Заголовок 2
|
||||||
|
|
||||||
|
- Элемент списка 1
|
||||||
|
- Элемент списка 2
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
### Заголовок 3
|
||||||
|
|
||||||
|
Нумерованный список:
|
||||||
|
|
||||||
|
1. Первый
|
||||||
|
2. Второй
|
||||||
90
tests/test_handlers.py
Normal file
90
tests/test_handlers.py
Normal file
@@ -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 == "."
|
||||||
Reference in New Issue
Block a user