Compare commits

..

10 Commits

Author SHA1 Message Date
Mikan
d326e9a797 version 0.1.1 2025-12-09 23:50:02 +03:00
Mikan
d5295b7299 [AI]
ГОСТ
2025-12-09 13:43:18 +03:00
Mikan
4a48e8de85 [AI]
ГОСТ
2025-12-08 19:49:15 +03:00
Mikan
cc98c9cf03 [AI]
ГОСТ
2025-12-08 19:29:36 +03:00
Mikan
9553c03c12 [AI]
Фикс создания файла
2025-12-08 19:28:50 +03:00
Mikan
d709f1dee2 [AI]
fix
2025-12-08 18:19:31 +03:00
Mikan
bba1e8ca7e [AI]
MVP готов
2025-12-08 17:48:35 +03:00
Mikan
5b08f6792a [AI]
Поддержка плагинов
2025-12-08 17:27:26 +03:00
Mikan
8da965234c [AI]
Поддержка тем
2025-12-08 17:21:41 +03:00
Mikan
c262bffb41 работа с шаблонами 2025-12-08 17:18:30 +03:00
22 changed files with 1260 additions and 70 deletions

2
.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
/inputs/
/outputs/

60
DEVELOPER.md Normal file
View 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
View 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
View 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)
- Изображения: `![alt](images/file.png)`
- Таблицы
- `<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
View 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"

View File

@@ -1,4 +1,5 @@
mistune>=3.0.0
python-docx
typer[all]
PyYAML
PyYAML
Pillow

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

View File

@@ -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()

View File

@@ -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()
doc = Document()
style_reg.ensure_styles_in_doc(doc) # добавляем стили
if template_path and template_path.exists():
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)
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
View 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}")

View File

@@ -2,16 +2,42 @@ 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 handler:
handler(node, doc, images_dir, style_reg)
elif node.get("children"):
for child in node["children"]:
walk(child)
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_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)

View File

@@ -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)
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")

View File

@@ -13,4 +13,6 @@ def register_handler(node_type: str):
return decorator
def get_handler(node_type: str) -> Handler:
return HANDLER_REGISTRY.get(node_type)
return HANDLER_REGISTRY.get(node_type)
from . import builtin

View File

@@ -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:
cells = table.add_row().cells
for i, cell_data in enumerate(row_data["children"]):
if i < num_cols:
cell = cells[i]
_render_inline_tokens(cell.paragraphs[0], cell_data["children"], doc, images_dir, style_reg)
for body_node in body_nodes:
for row_node in body_node["children"]: # table_row
cells = table.add_row().cells
for i, cell_node in enumerate(row_node["children"]): # table_cell
if i < num_cols:
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
View 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
View 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
View 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 |
![Рис. 1 — Диаграмма](images/chart.png)
"""
(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)

View File

@@ -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,18 +112,23 @@ 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
"type": "table_row",
{ # table_body
"type": "table_body",
"children": [
{"type": "table_cell", "children": [{"type": "text", "raw": "Ячейка 1"}]},
{"type": "table_cell", "children": [{"type": "text", "raw": "Ячейка 2"}]},
{ # table_row
"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 = {
"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"}]},
{ # table_row
"type": "table_row",
"children": [
{"type": "table_cell", "children": [{"type": "text", "raw": "Заголовок 1"}]},
{"type": "table_cell", "children": [{"type": "text", "raw": "Заголовок 2"}]},
]
}
]
},
{ # row 1
"type": "table_row",
{ # table_body
"type": "table_body",
"children": [
{"type": "table_cell", "children": [
{"type": "text", "raw": "Первая строка"},
{"type": "softbreak"}, # или linebreak/html
{"type": "text", "raw": "Вторая строка"}
]},
{"type": "table_cell", "children": [{"type": "text", "raw": "Ячейка 2"}]},
{ # row 1
"type": "table_row",
"children": [
{"type": "table_cell", "children": [
{"type": "text", "raw": "Первая строка"},
{"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Вторая строка"
assert "Первая строка" in cell_text
assert "Вторая строка" in cell_text

145
tests/test_page_layout.py Normal file
View 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
View 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)

View 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: Заголовок 'ВВЕДЕНИЕ' не найден"

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