initial MVP

This commit is contained in:
Mikan
2025-12-08 16:57:25 +03:00
commit 848eb462b5
20 changed files with 398 additions and 0 deletions

0
src/__init__.py Normal file
View File

16
src/__main__.py Normal file
View 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
View File

30
src/core/converter.py Normal file
View 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
View 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)

View 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
View 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
View 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"])