diff --git a/src/core/renderer.py b/src/core/renderer.py index c4c38ee..9991c66 100644 --- a/src/core/renderer.py +++ b/src/core/renderer.py @@ -2,8 +2,15 @@ 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): @@ -11,10 +18,25 @@ def render_markdown_to_docx(doc, md_content: str, images_dir, style_reg: StyleRe for item in node: walk(item) elif isinstance(node, dict): - handler = get_handler(node["type"]) + node_type = node["type"] + # Игнорируем вспомогательные узлы без логирования + if node_type in ("text", "blank_line", "table_head", "table_body", "table_row", "table_cell"): + if node.get("children"): + for child in node["children"]: + walk(child) + return + + if node_type not in log_handlers: + logger.debug(f"Обрабатываем узел: {node_type}") + handler = get_handler(node_type) if handler: + if node_type not in log_handlers: + logger.debug(f" → найден обработчик для {node_type}") handler(node, doc, images_dir, style_reg) - elif node.get("children"): + elif node_type not in log_handlers: + logger.warning(f" → нет обработчика для {node_type}") + log_handlers.add(node_type) + if node.get("children"): for child in node["children"]: walk(child) diff --git a/src/handlers/__init__.py b/src/handlers/__init__.py index 3f2cd29..d4e01cd 100644 --- a/src/handlers/__init__.py +++ b/src/handlers/__init__.py @@ -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) \ No newline at end of file + return HANDLER_REGISTRY.get(node_type) + +from . import builtin \ No newline at end of file diff --git a/src/handlers/builtin.py b/src/handlers/builtin.py index 8d9b4c4..ea90068 100644 --- a/src/handlers/builtin.py +++ b/src/handlers/builtin.py @@ -53,6 +53,7 @@ def _render_inline_tokens(p, tokens, doc, images_dir, style_reg): @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") @@ -61,10 +62,13 @@ def handle_image(node: dict, doc, images_dir: Path, style_reg): # mistune v3: node["attrs"]["url"], alt в children # старый формат: node["src"], node["alt"] src = node.get("attrs", {}).get("url") or node.get("src") - # alt: mistune v3 → children[0].raw, старый → node["alt"] 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()}") @@ -85,8 +89,8 @@ def handle_image(node: dict, doc, images_dir: Path, style_reg): cap_p = doc.add_paragraph(alt) style_reg.apply_style(cap_p.style, "Caption") else: - doc.add_paragraph(f"[Изображение не найдено: {src}]") logger.warning(f"[Изображение не найдено: {src}]") + doc.add_paragraph(f"[Изображение не найдено: {src}]") @register_handler("list") def handle_list(node: dict, doc, images_dir: Path, style_reg): @@ -98,28 +102,36 @@ def handle_list(node: dict, doc, images_dir: Path, 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) diff --git a/tests/test_gost_example.py b/tests/test_gost_example.py index ddfcf7b..bb82ef5 100644 --- a/tests/test_gost_example.py +++ b/tests/test_gost_example.py @@ -42,7 +42,7 @@ def create_gost_example(): import shutil shutil.rmtree(doc_dir.parent, ignore_errors=True) -def test_gost_example_document_not_empty(create_gost_example): +def test_gost_example_document_is_not_empty(create_gost_example): input_dir = create_gost_example process_document( @@ -54,11 +54,100 @@ def test_gost_example_document_not_empty(create_gost_example): output_path = Path("tests/output/gost_example.docx") assert output_path.exists(), "Файл .docx не создан" - # Открываем и проверяем, что в документе есть хотя бы 1 параграф + # Открываем и проверяем, что в документе есть хотя бы 1 непустой параграф doc = Document(output_path) - assert len(doc.paragraphs) > 0, "Документ пустой (нет параграфов)" - assert len(doc.tables) >= 0, "Документ не содержит таблиц (это нормально)" - assert any(p.text.strip() != "" for p in doc.paragraphs), "Все параграфы пустые" + + # Проверяем, что есть хотя бы 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) \ No newline at end of file diff --git a/tests/test_handlers.py b/tests/test_handlers.py index e2106b3..8b2ebf8 100644 --- a/tests/test_handlers.py +++ b/tests/test_handlers.py @@ -28,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") @@ -36,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 @@ -93,18 +94,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"}]}, + ] + } ] } ] @@ -124,22 +130,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"}]}, + ] + } ] } ] diff --git a/tests/test_real_cli_call.py b/tests/test_real_cli_call.py new file mode 100644 index 0000000..3f573b9 --- /dev/null +++ b/tests/test_real_cli_call.py @@ -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: Заголовок 'ВВЕДЕНИЕ' не найден" \ No newline at end of file