Files

425 lines
15 KiB
Python
Raw Permalink Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 '<w:pgSz w:w="11906" w:h="16838" w:orient="portrait"/>' 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 '<w:pgNumType w:start="1" w:fmt="decimal"/>' in document
assert '<w:pStyle w:val="DocumentTitle"/>' 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 '<w:ind w:left="0" w:hanging="0" w:firstLineChars="200"/>' 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 '<w:numFmt w:val="chineseCounting"/>' in numbering
assert '<w:lvlText w:val="%1、"/>' in numbering
assert '<w:lvl w:ilvl="1">' in numbering
assert '<w:lvlRestart w:val="1"/><w:isLgl/><w:lvlText w:val="%1.%2"/>' in numbering
assert '<w:lvlRestart w:val="2"/><w:isLgl/><w:lvlText w:val="%1.%2.%3"/>' in numbering
assert '<w:lvlRestart w:val="3"/><w:isLgl/><w:lvlText w:val="%1.%2.%3.%4"/>' in numbering
assert '<w:lvlRestart w:val="4"/><w:isLgl/><w:lvlText w:val="%1.%2.%3.%4.%5"/>' in numbering
assert numbering.count('w:firstLineChars="200"') == 3
assert '<w:evenAndOddHeaders/>' in settings
assert '<w:updateFields w:val="true"/>' 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 '<w:jc w:val="left"/>' in default_footer
assert '<w:jc w:val="right"/>' 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 "<w:drawing>" 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 "<w:drawing>" 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'<w:pStyle w:val="(Heading\d)"/><w:numPr><w:ilvl w:val="(\d)"/>',
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'<w:lvlText w:val="{expected}"/>' 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 '<w:pStyle w:val="Heading1"/>' in document
assert '<w:pStyle w:val="Heading2"/>' in document
assert "<w:numPr>" 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",
}