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"
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
mistune>=3.0.0
|
mistune>=3.0.0
|
||||||
python-docx
|
python-docx
|
||||||
typer[all]
|
typer[all]
|
||||||
PyYAML
|
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
|
import typer
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from .core.converter import process_document
|
from .core.converter import process_document
|
||||||
|
|
||||||
app = typer.Typer()
|
app = typer.Typer()
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@app.command()
|
@app.command()
|
||||||
def convert(
|
def convert(
|
||||||
input_dir: Path = typer.Option(..., "--input", "-i", help="Папка с исходниками"),
|
input_dir: Path = typer.Option(..., "--input", "-i", help="Папка с исходниками"),
|
||||||
output_dir: Path = typer.Option(..., "--output", "-o", 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__":
|
if __name__ == "__main__":
|
||||||
app()
|
app()
|
||||||
@@ -1,30 +1,58 @@
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
from docx import Document
|
from docx import Document
|
||||||
from .style_registry import StyleRegistry
|
from .style_registry import StyleRegistry
|
||||||
from .renderer import render_markdown_to_docx
|
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):
|
logger = logging.getLogger(__name__)
|
||||||
style_reg = StyleRegistry(style_config_path)
|
|
||||||
|
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():
|
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
|
continue
|
||||||
doc_name = doc_dir.name
|
doc_name = doc_dir.name
|
||||||
md_path = doc_dir / "document.md"
|
md_path = doc_dir / "document.md"
|
||||||
images_dir = doc_dir / "images"
|
images_dir = doc_dir / "images"
|
||||||
|
|
||||||
if not md_path.exists():
|
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
|
continue
|
||||||
|
|
||||||
with open(md_path, encoding="utf-8") as f:
|
with open(md_path, encoding="utf-8") as f:
|
||||||
md_content = f.read()
|
md_content = f.read()
|
||||||
|
|
||||||
doc = Document()
|
if template_path and template_path.exists():
|
||||||
style_reg.ensure_styles_in_doc(doc) # добавляем стили
|
doc = Document(template_path)
|
||||||
|
else:
|
||||||
|
doc = Document()
|
||||||
|
|
||||||
|
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)
|
render_markdown_to_docx(doc, md_content, images_dir, style_reg)
|
||||||
|
|
||||||
output_path = output_dir / f"{doc_name}.docx"
|
output_path = output_dir / f"{doc_name}.docx"
|
||||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
doc.save(output_path)
|
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,16 +2,42 @@ import mistune
|
|||||||
from .style_registry import StyleRegistry
|
from .style_registry import StyleRegistry
|
||||||
from ..handlers import get_handler
|
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):
|
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)
|
ast = markdown(md_content)
|
||||||
|
|
||||||
def walk(node):
|
def walk(node):
|
||||||
handler = get_handler(node["type"])
|
if isinstance(node, list):
|
||||||
if handler:
|
for item in node:
|
||||||
handler(node, doc, images_dir, style_reg)
|
walk(item)
|
||||||
elif node.get("children"):
|
elif isinstance(node, dict):
|
||||||
for child in node["children"]:
|
node_type = node["type"]
|
||||||
walk(child)
|
# Игнорируем вспомогательные узлы без логирования
|
||||||
|
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_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)
|
||||||
|
|
||||||
walk(ast)
|
walk(ast)
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
import yaml
|
import yaml
|
||||||
from docx import Document
|
from docx import Document
|
||||||
from docx.enum.style import WD_STYLE_TYPE
|
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.enum.text import WD_LINE_SPACING, WD_ALIGN_PARAGRAPH
|
||||||
from docx.styles.style import ParagraphStyle
|
|
||||||
|
|
||||||
class StyleRegistry:
|
class StyleRegistry:
|
||||||
def __init__(self, config_path):
|
def __init__(self, config_path):
|
||||||
@@ -14,13 +14,33 @@ class StyleRegistry:
|
|||||||
"""Создаёт недостающие стили в документе, если их нет."""
|
"""Создаёт недостающие стили в документе, если их нет."""
|
||||||
for name, cfg in self.config["styles"].items():
|
for name, cfg in self.config["styles"].items():
|
||||||
try:
|
try:
|
||||||
doc.styles[name]
|
style = doc.styles[name]
|
||||||
|
print(f"DEBUG: Стиль {name} уже существует, использую его")
|
||||||
except KeyError:
|
except KeyError:
|
||||||
# Создаём стиль
|
print(f"DEBUG: Стиль {name} не найден, создаю новый")
|
||||||
style = doc.styles.add_style(name, WD_STYLE_TYPE.PARAGRAPH)
|
style = doc.styles.add_style(name, WD_STYLE_TYPE.PARAGRAPH)
|
||||||
self.apply_style(style, name)
|
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):
|
def apply_style(self, style, style_name: str):
|
||||||
|
print(f"DEBUG: Применяю стиль {style_name} к {style.name}")
|
||||||
cfg = self.config["styles"].get(style_name)
|
cfg = self.config["styles"].get(style_name)
|
||||||
if not cfg:
|
if not cfg:
|
||||||
return
|
return
|
||||||
@@ -30,11 +50,21 @@ class StyleRegistry:
|
|||||||
|
|
||||||
if font_cfg:
|
if font_cfg:
|
||||||
font = style.font
|
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:
|
if "size" in font_cfg:
|
||||||
font.size = Pt(font_cfg["size"])
|
font.size = Pt(font_cfg["size"])
|
||||||
font.bold = font_cfg.get("bold", font.bold)
|
if "bold" in font_cfg:
|
||||||
font.italic = font_cfg.get("italic", font.italic)
|
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:
|
if para_cfg:
|
||||||
pf = style.paragraph_format
|
pf = style.paragraph_format
|
||||||
@@ -50,6 +80,12 @@ class StyleRegistry:
|
|||||||
align = alignment_map.get(para_cfg["alignment"])
|
align = alignment_map.get(para_cfg["alignment"])
|
||||||
if align:
|
if align:
|
||||||
pf.alignment = 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):
|
def get_color(self, key: str):
|
||||||
hex_color = self.config.get("colors", {}).get(key, "000000")
|
hex_color = self.config.get("colors", {}).get(key, "000000")
|
||||||
|
|||||||
@@ -13,4 +13,6 @@ def register_handler(node_type: str):
|
|||||||
return decorator
|
return decorator
|
||||||
|
|
||||||
def get_handler(node_type: str) -> Handler:
|
def get_handler(node_type: str) -> Handler:
|
||||||
return HANDLER_REGISTRY.get(node_type)
|
return HANDLER_REGISTRY.get(node_type)
|
||||||
|
|
||||||
|
from . import builtin
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import logging
|
import logging
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
@@ -8,9 +8,14 @@ from src.handlers import register_handler
|
|||||||
from docx.shared import Inches
|
from docx.shared import Inches
|
||||||
from docx.enum.text import WD_ALIGN_PARAGRAPH
|
from docx.enum.text import WD_ALIGN_PARAGRAPH
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@register_handler("heading")
|
@register_handler("heading")
|
||||||
def handle_heading(node: dict, doc, images_dir: Path, style_reg):
|
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 ""
|
text = node["children"][0]["raw"] if node["children"] else ""
|
||||||
doc.add_heading(text, level=level)
|
doc.add_heading(text, level=level)
|
||||||
p = doc.paragraphs[-1]
|
p = doc.paragraphs[-1]
|
||||||
@@ -44,19 +49,33 @@ def _render_inline_tokens(p, tokens, doc, images_dir, style_reg):
|
|||||||
p.add_run().add_break()
|
p.add_run().add_break()
|
||||||
elif token["type"] == "image":
|
elif token["type"] == "image":
|
||||||
handle_image(token, doc, images_dir, style_reg)
|
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")
|
@register_handler("paragraph")
|
||||||
def handle_paragraph(node: dict, doc, images_dir: Path, style_reg):
|
def handle_paragraph(node: dict, doc, images_dir: Path, style_reg):
|
||||||
p = doc.add_paragraph()
|
p = doc.add_paragraph()
|
||||||
|
# mistune v3: node["children"] содержит [{"type": "text", "raw": "Текст"}, ...]
|
||||||
_render_inline_tokens(p, node["children"], doc, images_dir, style_reg)
|
_render_inline_tokens(p, node["children"], doc, images_dir, style_reg)
|
||||||
style_reg.apply_style(p.style, "Normal")
|
style_reg.apply_style(p.style, "Normal")
|
||||||
|
|
||||||
@register_handler("image")
|
@register_handler("image")
|
||||||
def handle_image(node: dict, doc, images_dir: Path, style_reg):
|
def handle_image(node: dict, doc, images_dir: Path, style_reg):
|
||||||
src = node["src"]
|
# mistune v3: node["attrs"]["url"], alt в children
|
||||||
alt = node.get("alt", "")
|
# старый формат: 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
|
img_path = images_dir / src
|
||||||
if img_path.exists():
|
if img_path.exists():
|
||||||
|
logger.debug(f"Обработка изображения: {img_path}, exists: {img_path.exists()}")
|
||||||
p = doc.add_paragraph()
|
p = doc.add_paragraph()
|
||||||
run = p.add_run()
|
run = p.add_run()
|
||||||
try:
|
try:
|
||||||
@@ -66,49 +85,58 @@ def handle_image(node: dict, doc, images_dir: Path, style_reg):
|
|||||||
logger.error(f"Ошибка при добавлении изображения {img_path}: {e}")
|
logger.error(f"Ошибка при добавлении изображения {img_path}: {e}")
|
||||||
doc.add_paragraph(f"[Изображение не загружено: {src}]")
|
doc.add_paragraph(f"[Изображение не загружено: {src}]")
|
||||||
return
|
return
|
||||||
|
|
||||||
if alt:
|
if alt:
|
||||||
try:
|
try:
|
||||||
cap_p = doc.add_paragraph(alt, style="Caption")
|
cap_p = doc.add_paragraph(alt, style="Caption")
|
||||||
except KeyError:
|
except KeyError:
|
||||||
cap_p = doc.add_paragraph(alt)
|
cap_p = doc.add_paragraph(alt)
|
||||||
style_reg.apply_style(cap_p.style, "Caption")
|
style_reg.apply_style(cap_p.style, "Caption")
|
||||||
logger.debug(f"Добавлена подпись без стиля: {alt}")
|
|
||||||
else:
|
else:
|
||||||
|
logger.warning(f"[Изображение не найдено: {src}]")
|
||||||
doc.add_paragraph(f"[Изображение не найдено: {src}]")
|
doc.add_paragraph(f"[Изображение не найдено: {src}]")
|
||||||
logger.error(f"[Изображение не найдено: {src}]")
|
|
||||||
|
|
||||||
@register_handler("list")
|
@register_handler("list")
|
||||||
def handle_list(node: dict, doc, images_dir: Path, style_reg):
|
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"]:
|
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)
|
_render_inline_tokens(p, item["children"], doc, images_dir, style_reg)
|
||||||
|
|
||||||
|
|
||||||
@register_handler("table")
|
@register_handler("table")
|
||||||
def handle_table(node: dict, doc, images_dir: Path, style_reg):
|
def handle_table(node: dict, doc, images_dir: Path, style_reg):
|
||||||
rows_data = node["children"] # [header_row, row1, row2...]
|
logger.debug(f"Обработка таблицы: {node}")
|
||||||
if not rows_data:
|
# mistune v3: table.children = [{"type": "table_head", ...}, {"type": "table_body", ...}]
|
||||||
return
|
header_node = node["children"][0] # table_head
|
||||||
|
body_nodes = node["children"][1:] # table_body...
|
||||||
|
|
||||||
header_row = rows_data[0]["children"]
|
# header_node.children = [table_cell, table_cell...]
|
||||||
body_rows = rows_data[1:]
|
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 = doc.add_table(rows=1, cols=num_cols)
|
||||||
table.style = "Table Grid" # можно настроить в YAML
|
table.style = "Table Grid"
|
||||||
|
|
||||||
# Заполняем заголовок
|
# Заполняем заголовки
|
||||||
hdr_cells = table.rows[0].cells
|
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:
|
if i < num_cols:
|
||||||
cell = hdr_cells[i]
|
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:
|
||||||
cells = table.add_row().cells
|
for row_node in body_node["children"]: # table_row
|
||||||
for i, cell_data in enumerate(row_data["children"]):
|
cells = table.add_row().cells
|
||||||
if i < num_cols:
|
for i, cell_node in enumerate(row_node["children"]): # table_cell
|
||||||
cell = cells[i]
|
if i < num_cols:
|
||||||
_render_inline_tokens(cell.paragraphs[0], cell_data["children"], doc, images_dir, style_reg)
|
cell = cells[i]
|
||||||
|
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
|
import pytest
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from docx import Document
|
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.handlers.builtin import handle_heading, handle_paragraph, handle_image, handle_list, handle_table
|
||||||
from src.core.style_registry import StyleRegistry
|
from src.core.style_registry import StyleRegistry
|
||||||
|
|
||||||
@@ -26,7 +28,7 @@ def test_handle_image():
|
|||||||
doc = Document()
|
doc = Document()
|
||||||
style_reg = StyleRegistry(Path("resources/style_config.yaml"))
|
style_reg = StyleRegistry(Path("resources/style_config.yaml"))
|
||||||
style_reg.ensure_styles_in_doc(doc)
|
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")
|
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 = Image.new("RGB", (100, 100), color="red")
|
||||||
img.save(img_path)
|
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 параграфа: картинка и подпись
|
# После вызова должно быть 2 параграфа: картинка и подпись
|
||||||
assert len(doc.paragraphs) == 2
|
assert len(doc.paragraphs) == 2
|
||||||
@@ -84,6 +87,24 @@ def test_handle_list():
|
|||||||
assert doc.paragraphs[0].text == "Элемент 1"
|
assert doc.paragraphs[0].text == "Элемент 1"
|
||||||
assert doc.paragraphs[1].text == "Элемент 2"
|
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():
|
def test_handle_table():
|
||||||
doc = Document()
|
doc = Document()
|
||||||
@@ -91,18 +112,23 @@ def test_handle_table():
|
|||||||
node = {
|
node = {
|
||||||
"type": "table",
|
"type": "table",
|
||||||
"children": [
|
"children": [
|
||||||
{ # header
|
{ # table_head
|
||||||
"type": "table_row",
|
"type": "table_head",
|
||||||
"children": [
|
"children": [
|
||||||
{"type": "table_cell", "children": [{"type": "text", "raw": "Заголовок 1"}]},
|
{"type": "table_cell", "children": [{"type": "text", "raw": "Заголовок 1"}]},
|
||||||
{"type": "table_cell", "children": [{"type": "text", "raw": "Заголовок 2"}]},
|
{"type": "table_cell", "children": [{"type": "text", "raw": "Заголовок 2"}]},
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{ # row 1
|
{ # table_body
|
||||||
"type": "table_row",
|
"type": "table_body",
|
||||||
"children": [
|
"children": [
|
||||||
{"type": "table_cell", "children": [{"type": "text", "raw": "Ячейка 1"}]},
|
{ # table_row
|
||||||
{"type": "table_cell", "children": [{"type": "text", "raw": "Ячейка 2"}]},
|
"type": "table_row",
|
||||||
|
"children": [
|
||||||
|
{"type": "table_cell", "children": [{"type": "text", "raw": "Ячейка 1"}]},
|
||||||
|
{"type": "table_cell", "children": [{"type": "text", "raw": "Ячейка 2"}]},
|
||||||
|
]
|
||||||
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
@@ -122,22 +148,32 @@ def test_handle_table_with_br():
|
|||||||
node = {
|
node = {
|
||||||
"type": "table",
|
"type": "table",
|
||||||
"children": [
|
"children": [
|
||||||
{ # header
|
{ # table_head
|
||||||
"type": "table_row",
|
"type": "table_head",
|
||||||
"children": [
|
"children": [
|
||||||
{"type": "table_cell", "children": [{"type": "text", "raw": "Заголовок 1"}]},
|
{ # table_row
|
||||||
{"type": "table_cell", "children": [{"type": "text", "raw": "Заголовок 2"}]},
|
"type": "table_row",
|
||||||
|
"children": [
|
||||||
|
{"type": "table_cell", "children": [{"type": "text", "raw": "Заголовок 1"}]},
|
||||||
|
{"type": "table_cell", "children": [{"type": "text", "raw": "Заголовок 2"}]},
|
||||||
|
]
|
||||||
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{ # row 1
|
{ # table_body
|
||||||
"type": "table_row",
|
"type": "table_body",
|
||||||
"children": [
|
"children": [
|
||||||
{"type": "table_cell", "children": [
|
{ # row 1
|
||||||
{"type": "text", "raw": "Первая строка"},
|
"type": "table_row",
|
||||||
{"type": "softbreak"}, # или linebreak/html
|
"children": [
|
||||||
{"type": "text", "raw": "Вторая строка"}
|
{"type": "table_cell", "children": [
|
||||||
]},
|
{"type": "text", "raw": "Первая строка"},
|
||||||
{"type": "table_cell", "children": [{"type": "text", "raw": "Ячейка 2"}]},
|
{"type": "softbreak"}, # или linebreak/html
|
||||||
|
{"type": "text", "raw": "Вторая строка"}
|
||||||
|
]},
|
||||||
|
{"type": "table_cell", "children": [{"type": "text", "raw": "Ячейка 2"}]},
|
||||||
|
]
|
||||||
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
@@ -149,3 +185,4 @@ def test_handle_table_with_br():
|
|||||||
cell_text = table.cell(1, 0).text # "Первая строка\nВторая строка"
|
cell_text = table.cell(1, 0).text # "Первая строка\nВторая строка"
|
||||||
assert "Первая строка" in cell_text
|
assert "Первая строка" in cell_text
|
||||||
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