from __future__ import annotations import base64 import hashlib import importlib.util import io import re import sys import zipfile from pathlib import Path from types import SimpleNamespace from xml.etree import ElementTree import pytest BACKEND_ROOT = Path(__file__).resolve().parents[1] MODULE_PATH = BACKEND_ROOT / "app" / "gateway" / "word_export.py" def _load_word_export_module(): """Load the leaf module without executing app.gateway.__init__.""" spec = importlib.util.spec_from_file_location("word_export_test_module", MODULE_PATH) assert spec is not None and spec.loader is not None module = importlib.util.module_from_spec(spec) sys.modules[spec.name] = module spec.loader.exec_module(module) return module word_export = _load_word_export_module() def test_bundled_font_is_exact_and_allows_editable_embedding() -> None: font = word_export.load_title_font() assert font.sha256 == "C52D577DD3AA719EF5FB9FF2043B7F7CA36FAE468EF34A47628A3A555D006A49" assert font.fs_type == 0x0008 assert any("FZXiaoBiaoSong" in name for name in font.names) def test_formal_docx_contains_required_layout_numbering_and_embedded_font(tmp_path: Path) -> None: markdown = """# 样例标题 ## 一、一级标题 这是正文,用于验证首行缩进、两端对齐和固定行距。 ### 2.1 二级标题 #### 2.1.1 三级标题 --- 表1 测试表格 | 项目 | 数值 | |---|---:| | 页面 | A4 纵向 | """ docx_path = tmp_path / "sample.docx" docx_path.write_bytes(word_export.build_markdown_docx(markdown)) with zipfile.ZipFile(docx_path) as archive: names = set(archive.namelist()) assert "word/fonts/font1.odttf" in names assert "word/_rels/fontTable.xml.rels" in names for name in names: if name.endswith(".xml") or name.endswith(".rels"): ElementTree.fromstring(archive.read(name)) document = archive.read("word/document.xml").decode("utf-8") styles = archive.read("word/styles.xml").decode("utf-8") numbering = archive.read("word/numbering.xml").decode("utf-8") settings = archive.read("word/settings.xml").decode("utf-8") font_table = archive.read("word/fontTable.xml").decode("utf-8") default_footer = archive.read("word/footer1.xml").decode("utf-8") even_footer = archive.read("word/footer2.xml").decode("utf-8") assert '' in document assert 'w:top="2098"' in document assert 'w:bottom="1984"' in document assert 'w:left="1587"' in document assert 'w:right="1474"' in document assert '' in document assert '' in document assert "方正小标宋简体" in document assert "一、一级标题" not in document assert "2.1 二级标题" not in document assert "2.1.1 三级标题" not in document assert "────────" not in document assert "二级标题" in document assert "三级标题" in document assert 'w:line="579" w:lineRule="exact"' in styles assert '' in styles assert "方正小标宋简体" in styles assert 'w:val="44"' in styles assert "仿宋" in styles and 'w:val="32"' in styles assert "宋体" in styles and 'w:val="28"' in styles assert styles.count('w:firstLineChars="200"') >= 6 assert '' in numbering assert '' in numbering assert '' in numbering assert '' in numbering assert '' in numbering assert '' in numbering assert '' in numbering assert numbering.count('w:firstLineChars="200"') == 3 assert '' in settings assert '' in settings assert "方正小标宋简体" in font_table key_match = re.search(r'w:fontKey="\{([0-9A-F-]{36})\}"', font_table) assert key_match is not None font_key = key_match.group(1) restored = word_export._obfuscate_font(archive.read("word/fonts/font1.odttf"), font_key) assert hashlib.sha256(restored).hexdigest().upper() == word_export._BUNDLED_FONT_SHA256 # Word's default footer applies to odd pages; the separate even footer # applies to even pages. The requested alignment is odd-left/even-right. assert '' in default_footer assert '' in even_footer assert " PAGE " in default_footer and " PAGE " in even_footer _TINY_PNG = base64.b64decode( "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==" ) def test_embedded_png_is_written_into_docx_media() -> None: markdown = "正文前\n\n![流程图](embedded:mermaid-1)\n\n正文后" payload = word_export.build_markdown_docx( markdown, embedded_images={"mermaid-1": base64.b64encode(_TINY_PNG).decode("ascii")}, ) with zipfile.ZipFile(io.BytesIO(payload)) as archive: assert archive.read("word/media/image1.png") == _TINY_PNG document = archive.read("word/document.xml").decode("utf-8") rels = archive.read("word/_rels/document.xml.rels").decode("utf-8") types = archive.read("[Content_Types].xml").decode("utf-8") assert "" in document assert 'w:lineRule="auto"' in document assert "flowchart TB" not in document assert 'Target="media/image1.png"' in rels assert 'Extension="png"' in types def test_mermaid_fence_without_image_stays_code() -> None: markdown = "```mermaid\nflowchart TB\nA-->B\n```" payload = word_export.build_markdown_docx(markdown) with zipfile.ZipFile(io.BytesIO(payload)) as archive: document = archive.read("word/document.xml").decode("utf-8") assert "flowchart TB" in document assert "" not in document assert "word/media/image1.png" not in archive.namelist() def test_export_rejects_wrong_font(tmp_path: Path) -> None: wrong_font = tmp_path / "wrong.ttf" wrong_font.write_bytes(b"not-a-font") with pytest.raises(word_export.WordExportFontError): word_export.build_markdown_docx("正文", font_path=wrong_font) @pytest.mark.parametrize( ("source", "expected"), [ ("一、一级标题", "一级标题"), ("(一) 二级标题", "二级标题"), ("(一)二级标题", "二级标题"), ("1. 三级标题", "三级标题"), ("2.1 二级标题", "二级标题"), ("2.1.1 三级标题", "三级标题"), ("2.1.1. 三级标题", "三级标题"), ("正文标题", "正文标题"), ], ) def test_manual_heading_number_is_removed_as_one_complete_prefix(source: str, expected: str) -> None: assert word_export._strip_manual_heading_number(source) == expected def test_all_markdown_heading_levels_map_to_word_heading_levels() -> None: markdown = """# 文档主标题 ## 一、一级标题 ### 1.1 二级标题 #### 1.1.1 三级标题 ##### 1.1.1.1 四级标题 ###### 1.1.1.1.1 五级标题 """ title, blocks = word_export._parse_markdown(markdown, None) headings = [block for block in blocks if isinstance(block, word_export.ParagraphBlock)] assert title == "文档主标题" assert [(block.kind, block.text, block.level) for block in headings] == [ ("heading", "一级标题", 0), ("heading", "二级标题", 1), ("heading", "三级标题", 2), ("heading", "四级标题", 3), ("heading", "五级标题", 4), ] payload = word_export.build_markdown_docx(markdown) with zipfile.ZipFile(io.BytesIO(payload)) as archive: document = archive.read("word/document.xml").decode("utf-8") mapped_levels = re.findall( r'', document, ) assert mapped_levels == [ ("Heading1", "0"), ("Heading2", "1"), ("Heading3", "2"), ("Heading4", "3"), ("Heading5", "4"), ] def test_tolerates_model_generated_atx_heading_variations() -> None: markdown = """ #模型生成的标题 ##一级标题 ## ###**1.1 二级标题** #### 1.1.1 三级标题 #####1.1.1.1 四级标题 ######1.1.1.1.1 五级标题 """ title, blocks = word_export._parse_markdown(markdown, None) headings = [block for block in blocks if getattr(block, "kind", "") == "heading"] assert title == "模型生成的标题" assert [(block.text, block.level) for block in headings] == [ ("一级标题", 0), ("二级标题", 1), ("三级标题", 2), ("四级标题", 3), ("五级标题", 4), ] def test_number_prefix_corrects_wrong_hash_depth_and_recovers_missing_hashes() -> None: markdown = """# 标题 ### 一、一级标题 正文。 ## 1.1 二级标题 正文。 **1.2 缺少井号的二级标题** 正文。 1.2.1 缺少井号的三级标题 正文。 (2)兼容旧格式四级标题 正文。 """ _, blocks = word_export._parse_markdown(markdown, None) headings = [block for block in blocks if getattr(block, "kind", "") == "heading"] assert [(block.text, block.level) for block in headings] == [ ("一级标题", 0), ("二级标题", 1), ("缺少井号的二级标题", 1), ("缺少井号的三级标题", 2), ("兼容旧格式四级标题", 3), ] def test_hierarchical_heading_without_hashes_or_blank_line_is_recovered() -> None: markdown = "正文没有空行。\n1.1 模型漏掉井号与空行\n下一段正文。" _, blocks = word_export._parse_markdown(markdown, "标题") headings = [block for block in blocks if getattr(block, "kind", "") == "heading"] assert [(block.text, block.level) for block in headings] == [ ("模型漏掉井号与空行", 1) ] def test_heading_tolerance_does_not_consume_routes_lists_or_fenced_code() -> None: markdown = """#/public/scheduled-tasks/123 1. 普通有序列表 ```markdown ###1.1 代码块里的示例 ``` """ title, blocks = word_export._parse_markdown(markdown, "显式标题") assert title == "显式标题" assert not any(getattr(block, "kind", "") == "heading" for block in blocks) assert any(getattr(block, "kind", "") == "ordered_list" for block in blocks) assert any(getattr(block, "kind", "") == "code" and "###1.1" in block.text for block in blocks) @pytest.mark.parametrize( ("style", "markdown", "expected"), [ ( "hybrid_decimal", "## 一、一级\n### 1.1 二级\n#### 1.1.1 三级", [("一级", 0), ("二级", 1), ("三级", 2)], ), ( "official_chinese", "## 一、一级\n### (一)二级\n#### 1. 三级\n##### (1)四级\n###### 1)五级", [("一级", 0), ("二级", 1), ("三级", 2), ("四级", 3), ("五级", 4)], ), ( "decimal", "## 1 一级\n### 1.1 二级\n#### 1.1.1 三级", [("一级", 0), ("二级", 1), ("三级", 2)], ), ( "chapter_section", "## 第一章 一级\n### 第一节 二级\n#### 一、三级\n##### (一)四级\n###### 1. 五级", [("一级", 0), ("二级", 1), ("三级", 2), ("四级", 3), ("五级", 4)], ), ( "none", "## 一级\n### 二级\n#### 三级", [("一级", 0), ("二级", 1), ("三级", 2)], ), ], ) def test_heading_prefixes_are_parsed_according_to_selected_preset( style: str, markdown: str, expected: list[tuple[str, int]], ) -> None: _, blocks = word_export._parse_markdown( markdown, None, heading_numbering_style=style, ) headings = [block for block in blocks if getattr(block, "kind", "") == "heading"] assert [(block.text, block.level) for block in headings] == expected @pytest.mark.parametrize( ("style", "expected_level_texts"), [ ("hybrid_decimal", ["%1、", "%1.%2", "%1.%2.%3", "%1.%2.%3.%4", "%1.%2.%3.%4.%5"]), ("official_chinese", ["%1、", "(%2)", "%3.", "(%4)", "%5)"]), ("decimal", ["%1", "%1.%2", "%1.%2.%3", "%1.%2.%3.%4", "%1.%2.%3.%4.%5"]), ("chapter_section", ["第%1章", "第%2节", "%3、", "(%4)", "%5."]), ], ) def test_heading_numbering_presets_emit_expected_word_patterns( style: str, expected_level_texts: list[str], ) -> None: numbering = word_export._numbering_xml(style) for expected in expected_level_texts: assert f'' in numbering ElementTree.fromstring(numbering) def test_no_numbering_preset_keeps_heading_styles_without_numpr() -> None: payload = word_export.build_markdown_docx( "# 文档标题\n\n## 一级标题\n\n### 二级标题", heading_numbering_style="none", ) with zipfile.ZipFile(io.BytesIO(payload)) as archive: document = archive.read("word/document.xml").decode("utf-8") assert '' in document assert '' in document assert "" not in document def test_unknown_heading_numbering_preset_is_rejected() -> None: with pytest.raises(word_export.WordExportError, match="未知的 Word 标题编号格式"): word_export.build_markdown_docx("正文", heading_numbering_style="unknown") @pytest.mark.asyncio async def test_export_route_uses_current_heading_numbering_setting(monkeypatch) -> None: from app.gateway.routers import writing captured: dict[str, object] = {} def fake_build(markdown, title, *, embedded_images, heading_numbering_style): captured.update( markdown=markdown, title=title, embedded_images=embedded_images, heading_numbering_style=heading_numbering_style, ) return b"docx" monkeypatch.setattr( writing, "load_system_settings", lambda: SimpleNamespace( prompt_prefix=SimpleNamespace(word_heading_numbering_style="official_chinese") ), ) monkeypatch.setattr(writing, "build_markdown_docx", fake_build) response = await writing.export_markdown_docx( writing.MarkdownDocxExportRequest(markdown="# 标题", title="测试") ) assert response.body == b"docx" assert captured == { "markdown": "# 标题", "title": "测试", "embedded_images": None, "heading_numbering_style": "official_chinese", }