Compare commits
10 Commits
1dbd05e57e
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d326e9a797 | ||
|
|
d5295b7299 | ||
|
|
4a48e8de85 | ||
|
|
cc98c9cf03 | ||
|
|
9553c03c12 | ||
|
|
d709f1dee2 | ||
|
|
bba1e8ca7e | ||
|
|
5b08f6792a | ||
|
|
8da965234c | ||
|
|
c262bffb41 |
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
/inputs/
|
||||
/outputs/
|
||||
60
DEVELOPER.md
Normal file
60
DEVELOPER.md
Normal file
@@ -0,0 +1,60 @@
|
||||
# Документация разработчика
|
||||
|
||||
## Архитектура
|
||||
|
||||
```
|
||||
src/
|
||||
├── __main__.py # CLI (typer)
|
||||
├── core/
|
||||
│ ├── converter.py # основная логика
|
||||
│ ├── renderer.py # парсинг и диспетчеризация
|
||||
│ └── style_registry.py # стили
|
||||
├── handlers/
|
||||
│ ├── __init__.py # реестр обработчиков
|
||||
│ └── builtin.py # встроенные обработчики
|
||||
├── plugins/ # плагины (пользовательские)
|
||||
└── utils.py # вспомогательные функции
|
||||
```
|
||||
|
||||
## Добавление нового обработчика
|
||||
|
||||
1. Создайте функцию:
|
||||
|
||||
```python
|
||||
@register_handler("new_element")
|
||||
def handle_new_element(node, doc, images_dir, style_reg):
|
||||
# ваш код
|
||||
pass
|
||||
```
|
||||
|
||||
2. Регистрируйте через `@register_handler("type")`
|
||||
3. В `mistune` должен быть соответствующий плагин (если нужен кастомный синтаксис)
|
||||
|
||||
## Стили
|
||||
|
||||
Стили определяются в YAML и применяются через `StyleRegistry`.
|
||||
|
||||
## Тестирование
|
||||
|
||||
```bash
|
||||
pytest tests/
|
||||
pytest tests/ -v -s # с логами
|
||||
```
|
||||
|
||||
Тесты покрывают:
|
||||
- Обработчики
|
||||
- Стили
|
||||
- CLI
|
||||
- Плагины
|
||||
|
||||
## Docker
|
||||
|
||||
Сборка: `docker build -t md2docx .`
|
||||
|
||||
Запуск: `docker run -v ./inputs:/app/inputs -v ./outputs:/app/outputs md2docx ...`
|
||||
|
||||
## CI/CD (опционально)
|
||||
|
||||
- Запуск тестов
|
||||
- Сборка Docker
|
||||
- Публикация в PyPI (опционально)
|
||||
28
Dockerfile
Normal file
28
Dockerfile
Normal file
@@ -0,0 +1,28 @@
|
||||
FROM python:3.11-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Копируем pyproject.toml, чтобы получить версию
|
||||
COPY pyproject.toml .
|
||||
|
||||
# Устанавливаем toml, чтобы получить версию
|
||||
RUN pip install toml
|
||||
RUN python -c "import toml; print('VERSION=' + toml.load('pyproject.toml')['project']['version'])" > version.txt
|
||||
RUN echo "VERSION=$(cat version.txt | cut -d= -f2)" >> /etc/environment
|
||||
|
||||
# Установка зависимостей
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
# Копируем исходники
|
||||
COPY src/ ./src/
|
||||
COPY resources/ ./resources/
|
||||
|
||||
# Папки для входных и выходных данных
|
||||
VOLUME ["/app/inputs", "/app/outputs"]
|
||||
|
||||
# Точка входа
|
||||
ENTRYPOINT ["python", "-m", "src"]
|
||||
|
||||
# Пример запуска:
|
||||
# docker run -v ./my_inputs:/app/inputs -v ./my_outputs:/app/outputs md2docx --input inputs --output outputs
|
||||
101
README.md
Normal file
101
README.md
Normal file
@@ -0,0 +1,101 @@
|
||||
# md-to-docx
|
||||
|
||||
Конвертер Markdown в Word (.docx) с поддержкой стилей, тем и плагинов.
|
||||
|
||||
## Установка
|
||||
|
||||
```bash
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
## Использование
|
||||
|
||||
### Структура входных данных:
|
||||
|
||||
```
|
||||
inputs/
|
||||
├── my_doc/
|
||||
│ ├── document.md
|
||||
│ └── images/
|
||||
│ ├── img1.png
|
||||
│ └── img2.jpg
|
||||
```
|
||||
|
||||
### Простой запуск:
|
||||
|
||||
```bash
|
||||
python -m src --input inputs --output outputs
|
||||
```
|
||||
|
||||
### С использованием темы:
|
||||
|
||||
```bash
|
||||
python -m src --input inputs --output outputs --theme academic
|
||||
```
|
||||
|
||||
### С шаблоном .dotx:
|
||||
|
||||
```bash
|
||||
python -m src --input inputs --output outputs --template template.dotx
|
||||
```
|
||||
|
||||
### С плагинами:
|
||||
|
||||
```bash
|
||||
python -m src --input inputs --output outputs --plugins my_plugins/
|
||||
```
|
||||
|
||||
### Дополнительные опции:
|
||||
|
||||
- `--verbose` / `-v` — подробный лог
|
||||
- `--quiet` / `-q` — без логов
|
||||
- `--dry-run` — показать, что будет сделано
|
||||
- `--style path/to/style.yaml` — использовать свой стиль
|
||||
|
||||
## Поддерживаемые элементы
|
||||
|
||||
- Заголовки `#`, `##`, `###`
|
||||
- Параграфы
|
||||
- Списки: `-`, `1.`
|
||||
- **Жирный**, *курсив*, [ссылки](https://example.com)
|
||||
- Изображения: ``
|
||||
- Таблицы
|
||||
- `<br>` внутри таблиц и параграфов
|
||||
|
||||
## Стили
|
||||
|
||||
Стили задаются в `resources/style_config.yaml` или через `--theme`.
|
||||
|
||||
Пример:
|
||||
|
||||
```yaml
|
||||
styles:
|
||||
Heading 1:
|
||||
font:
|
||||
name: "Times New Roman"
|
||||
size: 16
|
||||
bold: true
|
||||
```
|
||||
|
||||
## Плагины
|
||||
|
||||
Можно создать свой обработчик:
|
||||
|
||||
```python
|
||||
# plugins/my_block.py
|
||||
from src.handlers import register_handler
|
||||
|
||||
@register_handler("my_block")
|
||||
def handle_my_block(node, doc, images_dir, style_reg):
|
||||
# ваш код
|
||||
pass
|
||||
```
|
||||
|
||||
И использовать: `--plugins plugins/`
|
||||
|
||||
## Docker
|
||||
|
||||
```bash
|
||||
docker build -t md2docx .
|
||||
docker run -v ./inputs:/app/inputs -v ./outputs:/app/outputs md2docx --input inputs --output outputs
|
||||
```
|
||||
22
pyproject.toml
Normal file
22
pyproject.toml
Normal file
@@ -0,0 +1,22 @@
|
||||
[build-system]
|
||||
requires = ["setuptools>=45", "wheel"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "md-to-docx"
|
||||
version = "0.1.1"
|
||||
description = "Конвертер Markdown в Word (.docx) с поддержкой стилей, плагинов и тем"
|
||||
authors = [{name = "Mikan", email = "MikanDrawChannel@gmail.com"}]
|
||||
license = {text = "MIT"}
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.8"
|
||||
dependencies = [
|
||||
"mistune>=3.0.0",
|
||||
"python-docx",
|
||||
"typer[all]",
|
||||
"PyYAML",
|
||||
"Pillow"
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
md2docx = "src.__main__:app"
|
||||
@@ -2,3 +2,4 @@ mistune>=3.0.0
|
||||
python-docx
|
||||
typer[all]
|
||||
PyYAML
|
||||
Pillow
|
||||
87
resources/themes/gost.yaml
Normal file
87
resources/themes/gost.yaml
Normal file
@@ -0,0 +1,87 @@
|
||||
page:
|
||||
margins:
|
||||
top: 2.0 # cm
|
||||
bottom: 2.0
|
||||
left: 3.0
|
||||
right: 1.0
|
||||
|
||||
styles:
|
||||
Normal:
|
||||
font:
|
||||
name: "Times New Roman"
|
||||
size: 14
|
||||
paragraph:
|
||||
line_spacing: 1.5
|
||||
first_line_indent: 12.5 # mm
|
||||
space_after: 0
|
||||
|
||||
Heading 1:
|
||||
font:
|
||||
name: "Times New Roman"
|
||||
size: 14
|
||||
bold: true
|
||||
color: "000000" # черный
|
||||
paragraph:
|
||||
alignment: center
|
||||
space_before: 12
|
||||
space_after: 6
|
||||
keep_with_next: true
|
||||
|
||||
Heading 2:
|
||||
font:
|
||||
name: "Times New Roman"
|
||||
size: 14
|
||||
bold: true
|
||||
color: "000000"
|
||||
paragraph:
|
||||
alignment: center
|
||||
space_before: 10
|
||||
space_after: 4
|
||||
keep_with_next: true
|
||||
|
||||
Heading 3:
|
||||
font:
|
||||
name: "Times New Roman"
|
||||
size: 14
|
||||
bold: true
|
||||
color: "000000"
|
||||
paragraph:
|
||||
alignment: center
|
||||
space_before: 8
|
||||
space_after: 4
|
||||
keep_with_next: true
|
||||
|
||||
List Bullet:
|
||||
font:
|
||||
name: "Times New Roman"
|
||||
size: 14
|
||||
paragraph:
|
||||
left_indent: 0.5 # cm
|
||||
|
||||
List Number:
|
||||
font:
|
||||
name: "Times New Roman"
|
||||
size: 14
|
||||
paragraph:
|
||||
left_indent: 0.5
|
||||
|
||||
Caption:
|
||||
font:
|
||||
name: "Times New Roman"
|
||||
size: 14
|
||||
bold: false
|
||||
color: "000000"
|
||||
paragraph:
|
||||
alignment: center
|
||||
space_before: 4
|
||||
space_after: 4
|
||||
|
||||
Table Grid:
|
||||
font:
|
||||
name: "Times New Roman"
|
||||
size: 12
|
||||
paragraph:
|
||||
line_spacing: 1.0
|
||||
|
||||
colors:
|
||||
LinkColor: "0000FF"
|
||||
@@ -1,16 +1,44 @@
|
||||
from typing import Optional
|
||||
|
||||
import typer
|
||||
from pathlib import Path
|
||||
from .core.converter import process_document
|
||||
|
||||
app = typer.Typer()
|
||||
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@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="Файл стилей")
|
||||
style_config: Path = typer.Option("resources/style_config.yaml", "--style", "-s", help="Файл стилей"),
|
||||
template: Optional[Path] = typer.Option(None, "--template", "-t", help="Шаблон .dotx"),
|
||||
theme: Optional[str] = typer.Option(None, "--theme", "-T", help="Название темы (gost, academic...)"),
|
||||
plugins_dir: Optional[Path] = typer.Option(None, "--plugins", "-p", help="Папка с плагинами"),
|
||||
dry_run: bool = typer.Option(False, "--dry-run", help="Только показать, что будет сделано"),
|
||||
quiet: bool = typer.Option(False, "--quiet", "-q", help="Не выводить логи"),
|
||||
verbose: bool = typer.Option(False, "--verbose", "-v", help="Включить отладочные сообщения"),
|
||||
):
|
||||
process_document(input_dir, output_dir, style_config)
|
||||
if quiet:
|
||||
log_level = logging.WARNING
|
||||
elif verbose:
|
||||
log_level = logging.DEBUG
|
||||
else:
|
||||
log_level = logging.INFO
|
||||
|
||||
logging.basicConfig(level=log_level, format='%(levelname)s: %(message)s')
|
||||
|
||||
if dry_run:
|
||||
logger.info("Режим --dry-run: файлы не будут созданы")
|
||||
for doc_dir in input_dir.iterdir():
|
||||
if doc_dir.is_dir() and (doc_dir / "document.md").exists():
|
||||
logger.info(f"Будет обработан: {doc_dir.name}")
|
||||
return
|
||||
|
||||
process_document(input_dir, output_dir, style_config, template, theme, plugins_dir)
|
||||
|
||||
if __name__ == "__main__":
|
||||
app()
|
||||
@@ -1,30 +1,58 @@
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from docx import Document
|
||||
from .style_registry import StyleRegistry
|
||||
from .renderer import render_markdown_to_docx
|
||||
from .plugin_loader import load_plugins_from_path
|
||||
import logging
|
||||
|
||||
def process_document(input_dir: Path, output_dir: Path, style_config_path: Path):
|
||||
style_reg = StyleRegistry(style_config_path)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
def process_document(
|
||||
input_dir: Path, output_dir: Path, style_config_path: Path,
|
||||
template_path: Optional[Path] = None, theme_name: Optional[str] = None,
|
||||
plugins_dir: Optional[Path] = None, dry_run: bool = False
|
||||
):
|
||||
if theme_name:
|
||||
theme_path = Path("resources/themes") / f"{theme_name}.yaml"
|
||||
if theme_path.exists():
|
||||
style_config_path = theme_path
|
||||
else:
|
||||
logger.warning(f"Тема {theme_name} не найдена, используем стандартный стиль")
|
||||
|
||||
if plugins_dir and plugins_dir.exists():
|
||||
load_plugins_from_path(plugins_dir)
|
||||
|
||||
for doc_dir in input_dir.iterdir():
|
||||
if not doc_dir.is_dir():
|
||||
if not doc_dir.is_dir() or doc_dir.name == "images":
|
||||
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")
|
||||
logger.warning(f"Пропущено: {doc_name} — нет document.md")
|
||||
continue
|
||||
|
||||
if dry_run:
|
||||
logger.info(f"[DRY-RUN] Будет создан: {output_dir}/{doc_name}.docx")
|
||||
continue
|
||||
|
||||
with open(md_path, encoding="utf-8") as f:
|
||||
md_content = f.read()
|
||||
|
||||
if template_path and template_path.exists():
|
||||
doc = Document(template_path)
|
||||
else:
|
||||
doc = Document()
|
||||
style_reg.ensure_styles_in_doc(doc) # добавляем стили
|
||||
|
||||
style_reg = StyleRegistry(style_config_path)
|
||||
style_reg.ensure_styles_in_doc(doc)
|
||||
style_reg.apply_page_layout(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}")
|
||||
logger.info(f"Создан документ: {output_path}")
|
||||
16
src/core/plugin_loader.py
Normal file
16
src/core/plugin_loader.py
Normal file
@@ -0,0 +1,16 @@
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
from ..handlers import register_handler
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def load_plugins_from_path(plugins_dir: Path):
|
||||
"""Загружает все .py файлы из папки как плагины."""
|
||||
for py_file in plugins_dir.glob("*.py"):
|
||||
spec = importlib.util.spec_from_file_location(py_file.stem, py_file)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
# Теперь все @register_handler в module выполнены
|
||||
logger.info(f"Загружен плагин: {py_file.name}")
|
||||
@@ -2,15 +2,41 @@ import mistune
|
||||
from .style_registry import StyleRegistry
|
||||
from ..handlers import get_handler
|
||||
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
log_handlers = set()
|
||||
|
||||
def render_markdown_to_docx(doc, md_content: str, images_dir, style_reg: StyleRegistry):
|
||||
markdown = mistune.create_markdown(renderer=None)
|
||||
# Включаем плагины: таблицы, переносы строк
|
||||
markdown = mistune.create_markdown(renderer=None, plugins=['table'])
|
||||
ast = markdown(md_content)
|
||||
|
||||
def walk(node):
|
||||
handler = get_handler(node["type"])
|
||||
if isinstance(node, list):
|
||||
for item in node:
|
||||
walk(item)
|
||||
elif isinstance(node, dict):
|
||||
node_type = node["type"]
|
||||
# Игнорируем вспомогательные узлы без логирования
|
||||
if node_type in ("text", "blank_line", "table_head", "table_body", "table_row", "table_cell"):
|
||||
if node.get("children"):
|
||||
for child in node["children"]:
|
||||
walk(child)
|
||||
return
|
||||
|
||||
if node_type not in log_handlers:
|
||||
logger.debug(f"Обрабатываем узел: {node_type}")
|
||||
handler = get_handler(node_type)
|
||||
if handler:
|
||||
if node_type not in log_handlers:
|
||||
logger.debug(f" → найден обработчик для {node_type}")
|
||||
handler(node, doc, images_dir, style_reg)
|
||||
elif node.get("children"):
|
||||
elif node_type not in log_handlers:
|
||||
logger.warning(f" → нет обработчика для {node_type}")
|
||||
log_handlers.add(node_type)
|
||||
if node.get("children"):
|
||||
for child in node["children"]:
|
||||
walk(child)
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import yaml
|
||||
from docx import Document
|
||||
from docx.enum.style import WD_STYLE_TYPE
|
||||
from docx.shared import Pt, RGBColor
|
||||
from docx.oxml.ns import qn
|
||||
from docx.shared import Pt, Cm, 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):
|
||||
@@ -14,13 +14,33 @@ class StyleRegistry:
|
||||
"""Создаёт недостающие стили в документе, если их нет."""
|
||||
for name, cfg in self.config["styles"].items():
|
||||
try:
|
||||
doc.styles[name]
|
||||
style = doc.styles[name]
|
||||
print(f"DEBUG: Стиль {name} уже существует, использую его")
|
||||
except KeyError:
|
||||
# Создаём стиль
|
||||
print(f"DEBUG: Стиль {name} не найден, создаю новый")
|
||||
style = doc.styles.add_style(name, WD_STYLE_TYPE.PARAGRAPH)
|
||||
self.apply_style(style, name)
|
||||
|
||||
def apply_page_layout(self, doc: Document):
|
||||
"""Применяет настройки страницы (поля)."""
|
||||
page_cfg = self.config.get("page", {})
|
||||
margins = page_cfg.get("margins", {})
|
||||
if not margins:
|
||||
return
|
||||
|
||||
sections = doc.sections
|
||||
for section in sections:
|
||||
if "top" in margins:
|
||||
section.top_margin = Cm(margins["top"])
|
||||
if "bottom" in margins:
|
||||
section.bottom_margin = Cm(margins["bottom"])
|
||||
if "left" in margins:
|
||||
section.left_margin = Cm(margins["left"])
|
||||
if "right" in margins:
|
||||
section.right_margin = Cm(margins["right"])
|
||||
|
||||
def apply_style(self, style, style_name: str):
|
||||
print(f"DEBUG: Применяю стиль {style_name} к {style.name}")
|
||||
cfg = self.config["styles"].get(style_name)
|
||||
if not cfg:
|
||||
return
|
||||
@@ -30,11 +50,21 @@ class StyleRegistry:
|
||||
|
||||
if font_cfg:
|
||||
font = style.font
|
||||
font.name = font_cfg.get("name", font.name)
|
||||
if "name" in font_cfg:
|
||||
print(f"DEBUG: Устанавливаю font.name = {font_cfg['name']}")
|
||||
font.name = font_cfg["name"]
|
||||
font.element.rPr.rFonts.attrib.pop(qn("w:hAnsiTheme"), None)
|
||||
font.element.rPr.rFonts.attrib.pop(qn("w:AnsiTheme"), None)
|
||||
font.element.rPr.rFonts.set(qn('w:ascii'), font.name)
|
||||
font.element.rPr.rFonts.set(qn('w:hAnsi'), 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 "bold" in font_cfg:
|
||||
font.bold = font_cfg["bold"]
|
||||
if "italic" in font_cfg:
|
||||
font.italic = font_cfg["italic"]
|
||||
if "color" in font_cfg:
|
||||
font.color.rgb = RGBColor.from_string(font_cfg["color"])
|
||||
|
||||
if para_cfg:
|
||||
pf = style.paragraph_format
|
||||
@@ -50,6 +80,12 @@ class StyleRegistry:
|
||||
align = alignment_map.get(para_cfg["alignment"])
|
||||
if align:
|
||||
pf.alignment = align
|
||||
if "first_line_indent" in para_cfg:
|
||||
from docx.shared import Mm
|
||||
pf.first_line_indent = Mm(para_cfg["first_line_indent"])
|
||||
if "left_indent" in para_cfg:
|
||||
from docx.shared import Cm
|
||||
pf.left_indent = Cm(para_cfg["left_indent"])
|
||||
|
||||
def get_color(self, key: str):
|
||||
hex_color = self.config.get("colors", {}).get(key, "000000")
|
||||
|
||||
@@ -14,3 +14,5 @@ def register_handler(node_type: str):
|
||||
|
||||
def get_handler(node_type: str) -> Handler:
|
||||
return HANDLER_REGISTRY.get(node_type)
|
||||
|
||||
from . import builtin
|
||||
@@ -1,6 +1,6 @@
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
@@ -8,9 +8,14 @@ from src.handlers import register_handler
|
||||
from docx.shared import Inches
|
||||
from docx.enum.text import WD_ALIGN_PARAGRAPH
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@register_handler("heading")
|
||||
def handle_heading(node: dict, doc, images_dir: Path, style_reg):
|
||||
level = min(node["level"], 3)
|
||||
# mistune v3: node["attrs"]["level"]
|
||||
# тесты: node["level"]
|
||||
level = node.get("attrs", {}).get("level") or node.get("level")
|
||||
level = min(level, 3)
|
||||
text = node["children"][0]["raw"] if node["children"] else ""
|
||||
doc.add_heading(text, level=level)
|
||||
p = doc.paragraphs[-1]
|
||||
@@ -44,19 +49,33 @@ def _render_inline_tokens(p, tokens, doc, images_dir, style_reg):
|
||||
p.add_run().add_break()
|
||||
elif token["type"] == "image":
|
||||
handle_image(token, doc, images_dir, style_reg)
|
||||
elif token["type"] == "block_text":
|
||||
for child in token["children"]:
|
||||
if child["type"] == "text":
|
||||
run = p.add_run(child["raw"])
|
||||
|
||||
@register_handler("paragraph")
|
||||
def handle_paragraph(node: dict, doc, images_dir: Path, style_reg):
|
||||
p = doc.add_paragraph()
|
||||
# mistune v3: node["children"] содержит [{"type": "text", "raw": "Текст"}, ...]
|
||||
_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", "")
|
||||
# mistune v3: node["attrs"]["url"], alt в children
|
||||
# старый формат: node["src"], node["alt"]
|
||||
src = node.get("attrs", {}).get("url") or node.get("src")
|
||||
alt_nodes = node.get("children", [])
|
||||
alt = alt_nodes[0]["raw"] if alt_nodes else node.get("alt", "")
|
||||
|
||||
# Убираем "images/" из src, если images_dir уже указывает на images/
|
||||
if src.startswith("images/"):
|
||||
src = src[7:] # "images/" = 7 символов
|
||||
|
||||
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()
|
||||
try:
|
||||
@@ -66,49 +85,58 @@ def handle_image(node: dict, doc, images_dir: Path, style_reg):
|
||||
logger.error(f"Ошибка при добавлении изображения {img_path}: {e}")
|
||||
doc.add_paragraph(f"[Изображение не загружено: {src}]")
|
||||
return
|
||||
|
||||
if alt:
|
||||
try:
|
||||
cap_p = doc.add_paragraph(alt, style="Caption")
|
||||
except KeyError:
|
||||
cap_p = doc.add_paragraph(alt)
|
||||
style_reg.apply_style(cap_p.style, "Caption")
|
||||
logger.debug(f"Добавлена подпись без стиля: {alt}")
|
||||
else:
|
||||
logger.warning(f"[Изображение не найдено: {src}]")
|
||||
doc.add_paragraph(f"[Изображение не найдено: {src}]")
|
||||
logger.error(f"[Изображение не найдено: {src}]")
|
||||
|
||||
@register_handler("list")
|
||||
def handle_list(node: dict, doc, images_dir: Path, style_reg):
|
||||
ordered = node.get("ordered", False)
|
||||
ordered = (node.get("bullet", "-") == ".")
|
||||
for item in node["children"]:
|
||||
p = doc.add_paragraph(style="List Number" if ordered else "List Bullet")
|
||||
style_name = "List Number" if ordered else "List Bullet"
|
||||
p = doc.add_paragraph(style=style_name)
|
||||
_render_inline_tokens(p, item["children"], doc, images_dir, style_reg)
|
||||
|
||||
|
||||
@register_handler("table")
|
||||
def handle_table(node: dict, doc, images_dir: Path, style_reg):
|
||||
rows_data = node["children"] # [header_row, row1, row2...]
|
||||
if not rows_data:
|
||||
return
|
||||
logger.debug(f"Обработка таблицы: {node}")
|
||||
# mistune v3: table.children = [{"type": "table_head", ...}, {"type": "table_body", ...}]
|
||||
header_node = node["children"][0] # table_head
|
||||
body_nodes = node["children"][1:] # table_body...
|
||||
|
||||
header_row = rows_data[0]["children"]
|
||||
body_rows = rows_data[1:]
|
||||
# header_node.children = [table_cell, table_cell...]
|
||||
header_cells = header_node["children"]
|
||||
num_cols = len(header_cells)
|
||||
|
||||
num_cols = len(header_row)
|
||||
table = doc.add_table(rows=1, cols=num_cols)
|
||||
table.style = "Table Grid" # можно настроить в YAML
|
||||
table.style = "Table Grid"
|
||||
|
||||
# Заполняем заголовок
|
||||
# Заполняем заголовки
|
||||
hdr_cells = table.rows[0].cells
|
||||
for i, cell_data in enumerate(header_row):
|
||||
for i, cell_node in enumerate(header_cells):
|
||||
if i < num_cols:
|
||||
cell = hdr_cells[i]
|
||||
_render_inline_tokens(cell.paragraphs[0], cell_data["children"], doc, images_dir, style_reg)
|
||||
logger.debug(f"cell_node[{i}] = {cell_node}")
|
||||
# cell_node = table_cell, его children = [text, ...]
|
||||
children = cell_node.get("children", [])
|
||||
logger.debug(f"Заголовок ячейки {i}: children = {children}")
|
||||
_render_inline_tokens(cell.paragraphs[0], children, doc, images_dir, style_reg)
|
||||
|
||||
# Заполняем тело
|
||||
for row_data in body_rows:
|
||||
for body_node in body_nodes:
|
||||
for row_node in body_node["children"]: # table_row
|
||||
cells = table.add_row().cells
|
||||
for i, cell_data in enumerate(row_data["children"]):
|
||||
for i, cell_node in enumerate(row_node["children"]): # table_cell
|
||||
if i < num_cols:
|
||||
cell = cells[i]
|
||||
_render_inline_tokens(cell.paragraphs[0], cell_data["children"], doc, images_dir, style_reg)
|
||||
children = cell_node.get("children", [])
|
||||
logger.debug(f"Ячейка {i}: children = {children}")
|
||||
_render_inline_tokens(cell.paragraphs[0], children, doc, images_dir, style_reg)
|
||||
|
||||
84
tests/test_converter.py
Normal file
84
tests/test_converter.py
Normal file
@@ -0,0 +1,84 @@
|
||||
import pytest
|
||||
from pathlib import Path
|
||||
from docx import Document
|
||||
from src.core.converter import process_document
|
||||
|
||||
@pytest.fixture
|
||||
def create_example_doc():
|
||||
# Создаём inputs/example_doc/
|
||||
doc_dir = Path("tests/fixtures/inputs/example_doc")
|
||||
doc_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Создаём document.md
|
||||
md_content = "# Заголовок\n\nПараграф."
|
||||
(doc_dir / "document.md").write_text(md_content, encoding="utf-8")
|
||||
|
||||
# Создаём images/
|
||||
(doc_dir / "images").mkdir(exist_ok=True)
|
||||
|
||||
yield doc_dir.parent # возвращаем Path("tests/fixtures/inputs")
|
||||
|
||||
# Удаляем после теста
|
||||
import shutil
|
||||
shutil.rmtree(doc_dir.parent, ignore_errors=True)
|
||||
|
||||
def test_process_document_with_template(create_example_doc):
|
||||
# create_example_doc = Path("tests/fixtures/inputs")
|
||||
input_dir = create_example_doc
|
||||
|
||||
# Создать пустой .dotx файл для теста
|
||||
tpl_path = Path("tests/fixtures/template.dotx")
|
||||
tpl_path.parent.mkdir(exist_ok=True)
|
||||
doc = Document()
|
||||
doc.save(tpl_path)
|
||||
|
||||
# Запустить конвертацию с шаблоном
|
||||
process_document(
|
||||
input_dir=input_dir,
|
||||
output_dir=Path("tests/output"),
|
||||
style_config_path=Path("resources/style_config.yaml"),
|
||||
template_path=tpl_path
|
||||
)
|
||||
|
||||
output_path = Path("tests/output/example_doc.docx")
|
||||
assert output_path.exists()
|
||||
|
||||
# Удаляем файл после теста
|
||||
output_path.unlink(missing_ok=True)
|
||||
tpl_path.unlink(missing_ok=True)
|
||||
|
||||
def test_process_document_with_theme():
|
||||
# Создаём тему
|
||||
theme_dir = Path("resources/themes")
|
||||
theme_dir.mkdir(exist_ok=True)
|
||||
theme_path = theme_dir / "test_theme.yaml"
|
||||
theme_path.write_text("""
|
||||
styles:
|
||||
Heading 1:
|
||||
font:
|
||||
name: "Arial"
|
||||
size: 14
|
||||
bold: true
|
||||
paragraph:
|
||||
space_before: 10
|
||||
space_after: 5
|
||||
""", encoding="utf-8")
|
||||
|
||||
# Создаём фикстуру
|
||||
doc_dir = Path("tests/fixtures/inputs/theme_test")
|
||||
doc_dir.mkdir(parents=True, exist_ok=True)
|
||||
(doc_dir / "document.md").write_text("# Заголовок", encoding="utf-8")
|
||||
(doc_dir / "images").mkdir(exist_ok=True)
|
||||
|
||||
process_document(
|
||||
input_dir=Path("tests/fixtures/inputs"),
|
||||
output_dir=Path("tests/output"),
|
||||
style_config_path=Path("resources/style_config.yaml"),
|
||||
theme_name="test_theme"
|
||||
)
|
||||
|
||||
output_path = Path("tests/output/theme_test.docx")
|
||||
assert output_path.exists()
|
||||
|
||||
output_path.unlink(missing_ok=True)
|
||||
theme_path.unlink(missing_ok=True)
|
||||
140
tests/test_formatting.py
Normal file
140
tests/test_formatting.py
Normal file
@@ -0,0 +1,140 @@
|
||||
import pytest
|
||||
from pathlib import Path
|
||||
from docx import Document
|
||||
from docx.enum.text import WD_ALIGN_PARAGRAPH
|
||||
from src.core.style_registry import StyleRegistry
|
||||
|
||||
def test_heading_font_and_formatting():
|
||||
"""Тест: заголовки используют Times New Roman, жирные, по центру, черные."""
|
||||
yaml_content = """
|
||||
styles:
|
||||
Heading 1:
|
||||
font:
|
||||
name: "Times New Roman"
|
||||
size: 14
|
||||
bold: true
|
||||
color: "000000"
|
||||
paragraph:
|
||||
alignment: center
|
||||
space_before: 12
|
||||
space_after: 6
|
||||
"""
|
||||
yaml_path = Path("tests/fixtures/test_heading_format.yaml")
|
||||
yaml_path.parent.mkdir(exist_ok=True)
|
||||
yaml_path.write_text(yaml_content, encoding="utf-8")
|
||||
|
||||
doc = Document()
|
||||
style_reg = StyleRegistry(yaml_path)
|
||||
style_reg.ensure_styles_in_doc(doc)
|
||||
|
||||
# Добавляем заголовок
|
||||
doc.add_heading("Тестовый заголовок", level=1)
|
||||
|
||||
p = doc.paragraphs[0]
|
||||
style = p.style
|
||||
|
||||
# Проверяем, что стиль изменился
|
||||
# font.name не проверяем, т.к. python-docx не обновляет его "на лету"
|
||||
assert style.font.size.pt == 14
|
||||
assert style.font.bold is True
|
||||
|
||||
# Проверяем выравнивание через стиль
|
||||
from docx.enum.text import WD_ALIGN_PARAGRAPH
|
||||
assert style.paragraph_format.alignment == WD_ALIGN_PARAGRAPH.CENTER
|
||||
|
||||
yaml_path.unlink(missing_ok=True)
|
||||
|
||||
def test_list_formatting():
|
||||
"""Тест: списки используют Times New Roman, 14 пт."""
|
||||
yaml_content = """
|
||||
styles:
|
||||
List Bullet:
|
||||
font:
|
||||
name: "Times New Roman"
|
||||
size: 14
|
||||
paragraph:
|
||||
left_indent: 0.5
|
||||
List Number:
|
||||
font:
|
||||
name: "Times New Roman"
|
||||
size: 14
|
||||
paragraph:
|
||||
left_indent: 0.5
|
||||
"""
|
||||
yaml_path = Path("tests/fixtures/test_list_format.yaml")
|
||||
yaml_path.parent.mkdir(exist_ok=True)
|
||||
yaml_path.write_text(yaml_content, encoding="utf-8")
|
||||
|
||||
doc = Document()
|
||||
style_reg = StyleRegistry(yaml_path)
|
||||
style_reg.ensure_styles_in_doc(doc)
|
||||
|
||||
# Добавляем элементы списка
|
||||
p1 = doc.add_paragraph("Элемент 1", style="List Bullet")
|
||||
p2 = doc.add_paragraph("Элемент 2", style="List Number")
|
||||
|
||||
# Проверяем шрифт стиля (name не проверяем)
|
||||
assert p1.style.font.size.pt == 14
|
||||
assert p2.style.font.size.pt == 14
|
||||
|
||||
# Проверяем отступ через стиль
|
||||
assert abs(p1.style.paragraph_format.left_indent.cm - 0.5) < 0.01
|
||||
|
||||
yaml_path.unlink(missing_ok=True)
|
||||
|
||||
def test_caption_formatting():
|
||||
"""Тест: подпись (Caption) — Times New Roman, 14 пт, по центру, черная."""
|
||||
yaml_content = """
|
||||
styles:
|
||||
Caption:
|
||||
font:
|
||||
name: "Times New Roman"
|
||||
size: 14
|
||||
color: "000000"
|
||||
paragraph:
|
||||
alignment: center
|
||||
space_before: 4
|
||||
space_after: 4
|
||||
"""
|
||||
yaml_path = Path("tests/fixtures/test_caption_format.yaml")
|
||||
yaml_path.parent.mkdir(exist_ok=True)
|
||||
yaml_path.write_text(yaml_content, encoding="utf-8")
|
||||
|
||||
doc = Document()
|
||||
style_reg = StyleRegistry(yaml_path)
|
||||
style_reg.ensure_styles_in_doc(doc)
|
||||
|
||||
# Добавляем подпись
|
||||
cap_p = doc.add_paragraph("Рис. 1 — Описание", style="Caption")
|
||||
|
||||
# Проверяем шрифт стиля (name не проверяем)
|
||||
assert cap_p.style.font.size.pt == 14
|
||||
|
||||
# Проверяем выравнивание через стиль
|
||||
from docx.enum.text import WD_ALIGN_PARAGRAPH
|
||||
assert cap_p.style.paragraph_format.alignment == WD_ALIGN_PARAGRAPH.CENTER
|
||||
|
||||
yaml_path.unlink(missing_ok=True)
|
||||
|
||||
def test_list_items_contain_text():
|
||||
"""Тест: элементы списка содержат текст."""
|
||||
doc = Document()
|
||||
|
||||
# Добавляем элементы списка
|
||||
p1 = doc.add_paragraph("Первый элемент", style="List Bullet")
|
||||
p2 = doc.add_paragraph("Второй элемент", style="List Number")
|
||||
|
||||
assert p1.text == "Первый элемент"
|
||||
assert p2.text == "Второй элемент"
|
||||
|
||||
def test_list_bullet_character():
|
||||
"""Тест: символы списка (пока невозможно изменить через python-docx)."""
|
||||
# Это невозможно проверить напрямую через python-docx
|
||||
# Символы списка задаются на уровне Word и не контролируются через python-docx
|
||||
# Можно только проверить, что используется стиль List Bullet/Number
|
||||
doc = Document()
|
||||
p1 = doc.add_paragraph("Элемент", style="List Bullet")
|
||||
p2 = doc.add_paragraph("Элемент", style="List Number")
|
||||
|
||||
assert p1.style.name == "List Bullet"
|
||||
assert p2.style.name == "List Number"
|
||||
153
tests/test_gost_example.py
Normal file
153
tests/test_gost_example.py
Normal file
@@ -0,0 +1,153 @@
|
||||
import pytest
|
||||
from pathlib import Path
|
||||
from docx import Document
|
||||
from src.core.converter import process_document
|
||||
|
||||
@pytest.fixture
|
||||
def create_gost_example():
|
||||
# Создаём inputs/gost_example/
|
||||
doc_dir = Path("tests/fixtures/inputs/gost_example")
|
||||
doc_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Создаём document.md
|
||||
md_content = """
|
||||
# ВВЕДЕНИЕ
|
||||
|
||||
Текст введения.
|
||||
|
||||
## 1.1 Актуальность темы
|
||||
|
||||
Текст актуальности.
|
||||
|
||||
| Показатель | 2022 |
|
||||
|------------|------|
|
||||
| Выручка | 1000 |
|
||||
|
||||

|
||||
"""
|
||||
(doc_dir / "document.md").write_text(md_content, encoding="utf-8")
|
||||
|
||||
# Создаём images/
|
||||
(doc_dir / "images").mkdir(exist_ok=True)
|
||||
|
||||
# Создаём изображение
|
||||
from PIL import Image
|
||||
img_path = doc_dir / "images/chart.png"
|
||||
img = Image.new("RGB", (100, 100), color="blue")
|
||||
img.save(img_path)
|
||||
|
||||
yield doc_dir.parent # возвращаем Path("tests/fixtures/inputs")
|
||||
|
||||
# Удаляем после теста
|
||||
import shutil
|
||||
shutil.rmtree(doc_dir.parent, ignore_errors=True)
|
||||
|
||||
def test_gost_example_document_is_not_empty(create_gost_example):
|
||||
input_dir = create_gost_example
|
||||
|
||||
process_document(
|
||||
input_dir=input_dir,
|
||||
output_dir=Path("tests/output"),
|
||||
style_config_path=Path("resources/themes/gost.yaml"),
|
||||
)
|
||||
|
||||
output_path = Path("tests/output/gost_example.docx")
|
||||
assert output_path.exists(), "Файл .docx не создан"
|
||||
|
||||
# Открываем и проверяем, что в документе есть хотя бы 1 непустой параграф
|
||||
doc = Document(output_path)
|
||||
|
||||
# Проверяем, что есть хотя бы 1 параграф
|
||||
assert len(doc.paragraphs) > 0, f"Документ пустой: {len(doc.paragraphs)} параграфов"
|
||||
|
||||
# Проверяем, что хотя бы один параграф **не пустой**
|
||||
non_empty_paragraphs = [p for p in doc.paragraphs if p.text.strip() != ""]
|
||||
assert len(non_empty_paragraphs) > 0, f"Все параграфы пустые: {[p.text for p in doc.paragraphs]}"
|
||||
|
||||
# Проверяем, что есть хотя бы один заголовок "ВВЕДЕНИЕ"
|
||||
found_intro = any("ВВЕДЕНИЕ" in p.text for p in doc.paragraphs)
|
||||
assert found_intro, f"Заголовок 'ВВЕДЕНИЕ' не найден. Параграфы: {[p.text for p in doc.paragraphs]}"
|
||||
|
||||
# Проверяем, что есть текст "Текст введения"
|
||||
found_intro_text = any("Текст введения" in p.text for p in doc.paragraphs)
|
||||
assert found_intro_text, f"Текст 'Текст введения' не найден. Параграфы: {[p.text for p in doc.paragraphs]}"
|
||||
|
||||
# Удаляем после теста
|
||||
output_path.unlink(missing_ok=True)
|
||||
|
||||
|
||||
def test_gost_example_document_content(create_gost_example):
|
||||
input_dir = create_gost_example
|
||||
|
||||
process_document(
|
||||
input_dir=input_dir,
|
||||
output_dir=Path("tests/output"),
|
||||
style_config_path=Path("resources/themes/gost.yaml"),
|
||||
)
|
||||
|
||||
output_path = Path("tests/output/gost_example.docx")
|
||||
assert output_path.exists(), "Файл .docx не создан"
|
||||
|
||||
doc = Document(output_path)
|
||||
|
||||
# Проверим, что есть хотя бы 2 параграфа (заголовок + текст)
|
||||
assert len(doc.paragraphs) >= 2, f"Документ содержит только {len(doc.paragraphs)} параграфов"
|
||||
|
||||
# Проверим, что в параграфах есть нужный текст
|
||||
texts = [p.text for p in doc.paragraphs]
|
||||
assert "ВВЕДЕНИЕ" in texts, "Заголовок 'ВВЕДЕНИЕ' не найден"
|
||||
assert "Текст введения." in texts, "Текст введения не найден"
|
||||
assert "Текст актуальности." in texts, "Текст актуальности не найден"
|
||||
|
||||
# Проверим, что таблица добавлена
|
||||
assert len(doc.tables) >= 1, "Таблица не найдена в документе"
|
||||
|
||||
# Проверим, что в таблице есть нужные данные
|
||||
table = doc.tables[0]
|
||||
assert table.cell(0, 0).text == "Показатель"
|
||||
assert table.cell(0, 1).text == "2022" # заголовок
|
||||
assert table.cell(1, 0).text == "Выручка"
|
||||
assert table.cell(1, 1).text == "1000" # тело таблицы
|
||||
|
||||
# Удаляем после теста
|
||||
output_path.unlink(missing_ok=True)
|
||||
|
||||
def test_gost_example_document_is_not_empty_debug(create_gost_example):
|
||||
input_dir = create_gost_example
|
||||
|
||||
process_document(
|
||||
input_dir=input_dir,
|
||||
output_dir=Path("tests/output"),
|
||||
style_config_path=Path("resources/themes/gost.yaml"),
|
||||
)
|
||||
|
||||
output_path = Path("tests/output/gost_example.docx")
|
||||
assert output_path.exists(), "Файл .docx не создан"
|
||||
|
||||
# Открываем и проверяем, что в документе есть хотя бы 1 непустой параграф
|
||||
doc = Document(output_path)
|
||||
|
||||
print(f"Количество параграфов: {len(doc.paragraphs)}")
|
||||
for i, p in enumerate(doc.paragraphs):
|
||||
print(f"Параграф {i}: '{p.text}' (len={len(p.text)})")
|
||||
print(f" Количество runs: {len(p.runs)}")
|
||||
for j, run in enumerate(p.runs):
|
||||
print(f" Run {j}: '{run.text}' (len={len(run.text)})")
|
||||
|
||||
# Проверяем, что есть хотя бы 1 параграф
|
||||
assert len(doc.paragraphs) > 0, f"Документ пустой: {len(doc.paragraphs)} параграфов"
|
||||
|
||||
# Проверяем, что хотя бы один параграф **не пустой**
|
||||
non_empty_paragraphs = [p for p in doc.paragraphs if p.text.strip() != ""]
|
||||
assert len(non_empty_paragraphs) > 0, f"Все параграфы пустые: {[p.text for p in doc.paragraphs]}"
|
||||
|
||||
# Проверяем, что есть хотя бы один заголовок "ВВЕДЕНИЕ"
|
||||
found_intro = any("ВВЕДЕНИЕ" in p.text for p in doc.paragraphs)
|
||||
assert found_intro, f"Заголовок 'ВВЕДЕНИЕ' не найден. Параграфы: {[p.text for p in doc.paragraphs]}"
|
||||
|
||||
# Проверяем, что есть текст "Текст введения"
|
||||
found_intro_text = any("Текст введения" in p.text for p in doc.paragraphs)
|
||||
assert found_intro_text, f"Текст 'Текст введения' не найден. Параграфы: {[p.text for p in doc.paragraphs]}"
|
||||
|
||||
# Удаляем после теста
|
||||
output_path.unlink(missing_ok=True)
|
||||
@@ -1,6 +1,8 @@
|
||||
import pytest
|
||||
from pathlib import Path
|
||||
from docx import Document
|
||||
|
||||
from src.core.converter import process_document
|
||||
from src.handlers.builtin import handle_heading, handle_paragraph, handle_image, handle_list, handle_table
|
||||
from src.core.style_registry import StyleRegistry
|
||||
|
||||
@@ -26,7 +28,7 @@ def test_handle_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
|
||||
node = {"type": "image", "src": "images/logo.png", "alt": "Логотип"}
|
||||
|
||||
# Создаем реальное изображение
|
||||
img_path = Path("tests/fixtures/images/logo.png")
|
||||
@@ -34,7 +36,8 @@ def test_handle_image():
|
||||
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
|
||||
# images_dir должен указывать на папку images/
|
||||
handle_image(node, doc, Path("tests/fixtures/images"), style_reg)
|
||||
|
||||
# После вызова должно быть 2 параграфа: картинка и подпись
|
||||
assert len(doc.paragraphs) == 2
|
||||
@@ -84,6 +87,24 @@ def test_handle_list():
|
||||
assert doc.paragraphs[0].text == "Элемент 1"
|
||||
assert doc.paragraphs[1].text == "Элемент 2"
|
||||
|
||||
def test_handle_list_block_text():
|
||||
doc = Document()
|
||||
style_reg = StyleRegistry(Path("resources/style_config.yaml"))
|
||||
node = {
|
||||
"type": "list",
|
||||
"ordered": False,
|
||||
"children": [
|
||||
{"type": "list_item", "children": [{"type": "block_text", "children": [{"type": "text", "raw": "Элемент 1"}]}]},
|
||||
{"type": "list_item", "children": [{"type": "block_text", "children": [{"type": "text", "raw": "Элемент 2"}]}]},
|
||||
]
|
||||
}
|
||||
handle_list(node, doc, Path("."), style_reg)
|
||||
|
||||
assert len(doc.paragraphs) == 2
|
||||
assert doc.paragraphs[0].style.name == "List Bullet"
|
||||
assert doc.paragraphs[0].text == "Элемент 1"
|
||||
assert doc.paragraphs[1].text == "Элемент 2"
|
||||
|
||||
|
||||
def test_handle_table():
|
||||
doc = Document()
|
||||
@@ -91,14 +112,17 @@ def test_handle_table():
|
||||
node = {
|
||||
"type": "table",
|
||||
"children": [
|
||||
{ # header
|
||||
"type": "table_row",
|
||||
{ # table_head
|
||||
"type": "table_head",
|
||||
"children": [
|
||||
{"type": "table_cell", "children": [{"type": "text", "raw": "Заголовок 1"}]},
|
||||
{"type": "table_cell", "children": [{"type": "text", "raw": "Заголовок 2"}]},
|
||||
]
|
||||
},
|
||||
{ # row 1
|
||||
{ # table_body
|
||||
"type": "table_body",
|
||||
"children": [
|
||||
{ # table_row
|
||||
"type": "table_row",
|
||||
"children": [
|
||||
{"type": "table_cell", "children": [{"type": "text", "raw": "Ячейка 1"}]},
|
||||
@@ -107,6 +131,8 @@ def test_handle_table():
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
handle_table(node, doc, Path("."), style_reg)
|
||||
|
||||
assert len(doc.tables) == 1
|
||||
@@ -122,13 +148,21 @@ def test_handle_table_with_br():
|
||||
node = {
|
||||
"type": "table",
|
||||
"children": [
|
||||
{ # header
|
||||
{ # table_head
|
||||
"type": "table_head",
|
||||
"children": [
|
||||
{ # table_row
|
||||
"type": "table_row",
|
||||
"children": [
|
||||
{"type": "table_cell", "children": [{"type": "text", "raw": "Заголовок 1"}]},
|
||||
{"type": "table_cell", "children": [{"type": "text", "raw": "Заголовок 2"}]},
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{ # table_body
|
||||
"type": "table_body",
|
||||
"children": [
|
||||
{ # row 1
|
||||
"type": "table_row",
|
||||
"children": [
|
||||
@@ -142,6 +176,8 @@ def test_handle_table_with_br():
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
handle_table(node, doc, Path("."), style_reg)
|
||||
|
||||
assert len(doc.tables) == 1
|
||||
@@ -149,3 +185,4 @@ def test_handle_table_with_br():
|
||||
cell_text = table.cell(1, 0).text # "Первая строка\nВторая строка"
|
||||
assert "Первая строка" in cell_text
|
||||
assert "Вторая строка" in cell_text
|
||||
|
||||
|
||||
145
tests/test_page_layout.py
Normal file
145
tests/test_page_layout.py
Normal file
@@ -0,0 +1,145 @@
|
||||
import pytest
|
||||
from pathlib import Path
|
||||
from docx import Document
|
||||
from src.core.style_registry import StyleRegistry
|
||||
|
||||
def test_apply_page_layout_all_margins():
|
||||
"""Тест: все поля заданы."""
|
||||
yaml_content = """
|
||||
page:
|
||||
margins:
|
||||
top: 2.0
|
||||
bottom: 2.0
|
||||
left: 3.0
|
||||
right: 1.0
|
||||
styles:
|
||||
Normal:
|
||||
font:
|
||||
name: "Arial"
|
||||
size: 12
|
||||
"""
|
||||
yaml_path = Path("tests/fixtures/test_layout_all.yaml")
|
||||
yaml_path.parent.mkdir(exist_ok=True)
|
||||
yaml_path.write_text(yaml_content, encoding="utf-8")
|
||||
|
||||
doc = Document()
|
||||
style_reg = StyleRegistry(yaml_path)
|
||||
style_reg.apply_page_layout(doc)
|
||||
|
||||
section = doc.sections[0]
|
||||
assert abs(section.top_margin.cm - 2.0) < 0.01
|
||||
assert abs(section.bottom_margin.cm - 2.0) < 0.01
|
||||
assert abs(section.left_margin.cm - 3.0) < 0.01
|
||||
assert abs(section.right_margin.cm - 1.0) < 0.01
|
||||
|
||||
yaml_path.unlink(missing_ok=True)
|
||||
|
||||
def test_apply_page_layout_partial_margins():
|
||||
"""Тест: заданы не все поля."""
|
||||
yaml_content = """
|
||||
page:
|
||||
margins:
|
||||
top: 1.5
|
||||
left: 2.5
|
||||
styles:
|
||||
Normal:
|
||||
font:
|
||||
name: "Arial"
|
||||
size: 12
|
||||
"""
|
||||
yaml_path = Path("tests/fixtures/test_layout_partial.yaml")
|
||||
yaml_path.parent.mkdir(exist_ok=True)
|
||||
yaml_path.write_text(yaml_content, encoding="utf-8")
|
||||
|
||||
doc = Document()
|
||||
style_reg = StyleRegistry(yaml_path)
|
||||
style_reg.apply_page_layout(doc)
|
||||
|
||||
section = doc.sections[0]
|
||||
assert abs(section.top_margin.cm - 1.5) < 0.01
|
||||
assert abs(section.left_margin.cm - 2.5) < 0.01
|
||||
# Остальные поля не должны измениться (остаются по умолчанию)
|
||||
# У docx по умолчанию: top=2.54, bottom=2.54, left=3.17, right=3.17
|
||||
# Но мы их не трогаем, если в YAML нет
|
||||
|
||||
yaml_path.unlink(missing_ok=True)
|
||||
|
||||
def test_apply_page_layout_no_page_section():
|
||||
"""Тест: в YAML нет секции page."""
|
||||
yaml_content = """
|
||||
styles:
|
||||
Normal:
|
||||
font:
|
||||
name: "Arial"
|
||||
size: 12
|
||||
"""
|
||||
yaml_path = Path("tests/fixtures/test_layout_no_page.yaml")
|
||||
yaml_path.parent.mkdir(exist_ok=True)
|
||||
yaml_path.write_text(yaml_content, encoding="utf-8")
|
||||
|
||||
doc = Document()
|
||||
style_reg = StyleRegistry(yaml_path)
|
||||
style_reg.apply_page_layout(doc)
|
||||
|
||||
section = doc.sections[0]
|
||||
# Поля не должны измениться (остаются по умолчанию)
|
||||
# top=2.54 cm по умолчанию
|
||||
assert abs(section.top_margin.cm - 2.54) < 0.01
|
||||
|
||||
yaml_path.unlink(missing_ok=True)
|
||||
|
||||
def test_apply_page_layout_no_margins():
|
||||
"""Тест: в YAML есть page, но нет margins."""
|
||||
yaml_content = """
|
||||
page:
|
||||
other_option: value
|
||||
styles:
|
||||
Normal:
|
||||
font:
|
||||
name: "Arial"
|
||||
size: 12
|
||||
"""
|
||||
yaml_path = Path("tests/fixtures/test_layout_no_margins.yaml")
|
||||
yaml_path.parent.mkdir(exist_ok=True)
|
||||
yaml_path.write_text(yaml_content, encoding="utf-8")
|
||||
|
||||
doc = Document()
|
||||
style_reg = StyleRegistry(yaml_path)
|
||||
style_reg.apply_page_layout(doc)
|
||||
|
||||
section = doc.sections[0]
|
||||
# Поля не должны измениться
|
||||
assert abs(section.top_margin.cm - 2.54) < 0.01
|
||||
|
||||
yaml_path.unlink(missing_ok=True)
|
||||
|
||||
def test_apply_page_layout_zero_margins():
|
||||
"""Тест: нулевые поля."""
|
||||
yaml_content = """
|
||||
page:
|
||||
margins:
|
||||
top: 0.0
|
||||
bottom: 0.0
|
||||
left: 0.0
|
||||
right: 0.0
|
||||
styles:
|
||||
Normal:
|
||||
font:
|
||||
name: "Arial"
|
||||
size: 12
|
||||
"""
|
||||
yaml_path = Path("tests/fixtures/test_layout_zero.yaml")
|
||||
yaml_path.parent.mkdir(exist_ok=True)
|
||||
yaml_path.write_text(yaml_content, encoding="utf-8")
|
||||
|
||||
doc = Document()
|
||||
style_reg = StyleRegistry(yaml_path)
|
||||
style_reg.apply_page_layout(doc)
|
||||
|
||||
section = doc.sections[0]
|
||||
assert abs(section.top_margin.cm - 0.0) < 0.01
|
||||
assert abs(section.bottom_margin.cm - 0.0) < 0.01
|
||||
assert abs(section.left_margin.cm - 0.0) < 0.01
|
||||
assert abs(section.right_margin.cm - 0.0) < 0.01
|
||||
|
||||
yaml_path.unlink(missing_ok=True)
|
||||
26
tests/test_plugins.py
Normal file
26
tests/test_plugins.py
Normal file
@@ -0,0 +1,26 @@
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def test_load_plugins():
|
||||
# Создаём плагин
|
||||
plugin_dir = Path("tests/plugins")
|
||||
plugin_dir.mkdir(exist_ok=True)
|
||||
plugin_path = plugin_dir / "test_plugin.py"
|
||||
plugin_path.write_text("""
|
||||
from src.handlers import register_handler
|
||||
|
||||
@register_handler("admonition")
|
||||
def handle_admonition(node, doc, images_dir, style_reg):
|
||||
p = doc.add_paragraph("Тест плагина")
|
||||
""", encoding="utf-8")
|
||||
|
||||
# Загружаем
|
||||
from src.core.plugin_loader import load_plugins_from_path
|
||||
load_plugins_from_path(plugin_dir)
|
||||
|
||||
# Проверяем, что обработчик зарегистрирован
|
||||
from src.handlers import get_handler
|
||||
handler = get_handler("admonition")
|
||||
assert handler is not None
|
||||
|
||||
plugin_path.unlink(missing_ok=True)
|
||||
53
tests/test_real_cli_call.py
Normal file
53
tests/test_real_cli_call.py
Normal file
@@ -0,0 +1,53 @@
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
def test_real_cli_call_creates_non_empty_doc():
|
||||
import tempfile
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
tmp_path = Path(tmp_dir)
|
||||
input_dir = tmp_path / "inputs"
|
||||
output_dir = tmp_path / "outputs"
|
||||
input_dir.mkdir()
|
||||
output_dir.mkdir()
|
||||
|
||||
# Создаём папку example_doc
|
||||
doc_dir = input_dir / "example_doc"
|
||||
doc_dir.mkdir()
|
||||
(doc_dir / "document.md").write_text("# ВВЕДЕНИЕ\n\nТекст введения.", encoding="utf-8")
|
||||
(doc_dir / "images").mkdir()
|
||||
|
||||
# Запускаем CLI через subprocess
|
||||
result = subprocess.run([
|
||||
sys.executable, "-m", "src",
|
||||
"--input", str(input_dir),
|
||||
"--output", str(output_dir),
|
||||
"--style", "resources/style_config.yaml"
|
||||
], capture_output=True, text=True, cwd=Path(__file__).parent.parent) # запускаем из корня проекта
|
||||
|
||||
print("STDOUT:", result.stdout)
|
||||
print("STDERR:", result.stderr)
|
||||
print("Return code:", result.returncode)
|
||||
|
||||
assert result.returncode == 0, f"CLI завершился с ошибкой: {result.stderr}"
|
||||
|
||||
# Проверяем, что файл создан
|
||||
output_path = output_dir / "example_doc.docx"
|
||||
assert output_path.exists(), "Файл .docx не создан через CLI"
|
||||
|
||||
# Открываем и проверяем
|
||||
from docx import Document
|
||||
doc = Document(output_path)
|
||||
|
||||
print(f"Real CLI: Количество параграфов: {len(doc.paragraphs)}")
|
||||
for i, p in enumerate(doc.paragraphs):
|
||||
print(f"Real CLI: Параграф {i}: '{p.text}' (len={len(p.text)})")
|
||||
|
||||
# Проверяем, что есть хотя бы 1 непустой параграф
|
||||
non_empty_paragraphs = [p for p in doc.paragraphs if p.text.strip() != ""]
|
||||
assert len(non_empty_paragraphs) > 0, f"Real CLI: Все параграфы пустые: {[p.text for p in doc.paragraphs]}"
|
||||
|
||||
# Проверяем, что есть заголовок
|
||||
found_intro = any("ВВЕДЕНИЕ" in p.text for p in doc.paragraphs)
|
||||
assert found_intro, f"Real CLI: Заголовок 'ВВЕДЕНИЕ' не найден"
|
||||
87
tests/test_style_registry.py
Normal file
87
tests/test_style_registry.py
Normal file
@@ -0,0 +1,87 @@
|
||||
import pytest
|
||||
from pathlib import Path
|
||||
from docx import Document
|
||||
from src.core.style_registry import StyleRegistry
|
||||
|
||||
def test_apply_style_font_and_color():
|
||||
yaml_content = """
|
||||
styles:
|
||||
TestStyle:
|
||||
font:
|
||||
name: "Arial"
|
||||
size: 16
|
||||
bold: true
|
||||
italic: true
|
||||
color: "FF0000" # красный
|
||||
paragraph:
|
||||
alignment: center
|
||||
space_before: 10
|
||||
space_after: 5
|
||||
"""
|
||||
yaml_path = Path("tests/fixtures/test_style.yaml")
|
||||
yaml_path.parent.mkdir(exist_ok=True)
|
||||
yaml_path.write_text(yaml_content, encoding="utf-8")
|
||||
|
||||
doc = Document()
|
||||
style_reg = StyleRegistry(yaml_path)
|
||||
style_reg.ensure_styles_in_doc(doc)
|
||||
|
||||
# Проверим, что стиль создался
|
||||
style = doc.styles["TestStyle"]
|
||||
assert style.font.name == "Arial"
|
||||
assert style.font.size.pt == 16
|
||||
assert style.font.bold is True
|
||||
assert style.font.italic is True
|
||||
assert style.font.color.rgb == (0xFF, 0x00, 0x00)
|
||||
|
||||
# Проверим paragraph_format
|
||||
pf = style.paragraph_format
|
||||
from docx.enum.text import WD_ALIGN_PARAGRAPH
|
||||
assert pf.alignment == WD_ALIGN_PARAGRAPH.CENTER
|
||||
assert pf.space_before.pt == 10
|
||||
assert pf.space_after.pt == 5
|
||||
|
||||
yaml_path.unlink(missing_ok=True)
|
||||
|
||||
def test_apply_style_with_defaults():
|
||||
yaml_content = """
|
||||
styles:
|
||||
TestStyle:
|
||||
font:
|
||||
name: "Times New Roman"
|
||||
size: 14
|
||||
"""
|
||||
yaml_path = Path("tests/fixtures/test_style_defaults.yaml")
|
||||
yaml_path.parent.mkdir(exist_ok=True)
|
||||
yaml_path.write_text(yaml_content, encoding="utf-8")
|
||||
|
||||
doc = Document()
|
||||
style_reg = StyleRegistry(yaml_path)
|
||||
style_reg.ensure_styles_in_doc(doc)
|
||||
|
||||
style = doc.styles["TestStyle"]
|
||||
assert style.font.name == "Times New Roman"
|
||||
assert style.font.size.pt == 14
|
||||
# Проверим, что остальные параметры не изменились (None означает "не задано")
|
||||
assert style.font.bold is None
|
||||
assert style.font.italic is None
|
||||
|
||||
yaml_path.unlink(missing_ok=True)
|
||||
|
||||
def test_get_color():
|
||||
yaml_content = """
|
||||
colors:
|
||||
MyRed: "FF0000"
|
||||
MyBlue: "0000FF"
|
||||
"""
|
||||
yaml_path = Path("tests/fixtures/test_colors.yaml")
|
||||
yaml_path.parent.mkdir(exist_ok=True)
|
||||
yaml_path.write_text(yaml_content, encoding="utf-8")
|
||||
|
||||
style_reg = StyleRegistry(yaml_path)
|
||||
assert style_reg.get_color("MyRed") == (0xFF, 0x00, 0x00)
|
||||
assert style_reg.get_color("MyBlue") == (0x00, 0x00, 0xFF)
|
||||
# Проверим fallback
|
||||
assert style_reg.get_color("NonExistent") == (0x00, 0x00, 0x00) # чёрный
|
||||
|
||||
yaml_path.unlink(missing_ok=True)
|
||||
Reference in New Issue
Block a user