425 lines
15 KiB
Python
425 lines
15 KiB
Python
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\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",
|
||
}
|