初始化提交
This commit is contained in:
commit
887c1f449e
963 files changed
+166869
No files matched your search
@@ -0,0 +1,531 @@
|
||||
"""
|
||||
布局后处理单元测试(纯逻辑,不依赖引擎和服务)。
|
||||
|
||||
测试 `src.engine.layout` 中的纯函数:
|
||||
- sort_and_group:阅读顺序排序 + 行归组
|
||||
- group_paragraphs:段落分组
|
||||
- build_markdown_text:Markdown 格式化
|
||||
- 辅助函数
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from src.engine.layout import (
|
||||
_block_center_x,
|
||||
_block_center_y,
|
||||
_block_height,
|
||||
_detect_columns,
|
||||
_detect_heading_level,
|
||||
_detect_line_type,
|
||||
_sort_and_group_single,
|
||||
build_markdown_text,
|
||||
group_paragraphs,
|
||||
sort_and_group,
|
||||
)
|
||||
from src.schemas.ocr import BBox, TextBlock
|
||||
|
||||
|
||||
# ── 工厂函数 ──────────────────────────────────────────────
|
||||
|
||||
def _make_block(
|
||||
text: str,
|
||||
x1: float = 0,
|
||||
y1: float = 0,
|
||||
x2: float = 100,
|
||||
y2: float = 30,
|
||||
confidence: float = 0.95,
|
||||
) -> TextBlock:
|
||||
"""创建一个测试用的 TextBlock。"""
|
||||
return TextBlock(
|
||||
text=text,
|
||||
confidence=confidence,
|
||||
bbox=BBox(points=[[x1, y1], [x2, y1], [x2, y2], [x1, y2]]),
|
||||
)
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
# 辅助函数
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
class TestHelpers:
|
||||
"""辅助函数测试。"""
|
||||
|
||||
def test_block_height(self) -> None:
|
||||
block = _make_block("测试", y1=10, y2=50)
|
||||
assert _block_height(block) == 40.0
|
||||
|
||||
def test_block_height_empty(self) -> None:
|
||||
block = _make_block("测试", x1=0, y1=0, x2=0, y2=0)
|
||||
assert _block_height(block) == 0.0
|
||||
|
||||
def test_block_center_y(self) -> None:
|
||||
block = _make_block("测试", y1=10, y2=50)
|
||||
assert _block_center_y(block) == 30.0
|
||||
|
||||
def test_block_center_x(self) -> None:
|
||||
block = _make_block("测试", x1=20, x2=100)
|
||||
assert _block_center_x(block) == 60.0
|
||||
|
||||
def test_detect_heading_chinese(self) -> None:
|
||||
"""中文短行 → heading。"""
|
||||
assert _detect_line_type("第一章 绪论") == "heading"
|
||||
|
||||
def test_detect_heading_short_no_punct(self) -> None:
|
||||
"""短行不以句号结尾 → heading。"""
|
||||
assert _detect_line_type("摘要") == "heading"
|
||||
|
||||
def test_detect_not_heading_with_punct(self) -> None:
|
||||
"""以句号结尾的短行 → text。"""
|
||||
assert _detect_line_type("这是一个句子。") == "text"
|
||||
|
||||
def test_detect_long_text(self) -> None:
|
||||
"""长行(>= 40 字符)→ text。"""
|
||||
long = "一二三四五六七八九十" * 4
|
||||
assert _detect_line_type(long) == "text"
|
||||
|
||||
def test_detect_list_item_dash(self) -> None:
|
||||
assert _detect_line_type("- 列表项") == "list_item"
|
||||
|
||||
def test_detect_list_item_bullet(self) -> None:
|
||||
assert _detect_line_type("• 项目") == "list_item"
|
||||
|
||||
def test_detect_numbered(self) -> None:
|
||||
assert _detect_line_type("1. 首先") == "numbered"
|
||||
assert _detect_line_type("2) 其次") == "numbered"
|
||||
|
||||
def test_detect_separator(self) -> None:
|
||||
assert _detect_line_type("---") == "separator"
|
||||
assert _detect_line_type("===") == "separator"
|
||||
assert _detect_line_type("***") == "separator"
|
||||
|
||||
def test_detect_code(self) -> None:
|
||||
assert _detect_line_type(" def foo():") == "code"
|
||||
assert _detect_line_type("\t\tindented") == "code"
|
||||
|
||||
def test_detect_blockquote_as_text(self) -> None:
|
||||
"""> 前缀在 OCR 语境中通常是排版伪影,不作为代码块。"""
|
||||
assert _detect_line_type("> 引用") == "text"
|
||||
|
||||
def test_detect_empty_text(self) -> None:
|
||||
assert _detect_line_type("") == "text"
|
||||
assert _detect_line_type(" ") == "text"
|
||||
|
||||
def test_detect_centered_heading(self) -> None:
|
||||
"""居中短行 → 即使非中文也应视为 heading。"""
|
||||
assert _detect_line_type("Hello World", center_x_ratio=0.5) == "heading"
|
||||
|
||||
def test_detect_not_centered(self) -> None:
|
||||
"""不居中短行 + 非中文锁 → 不回退。"""
|
||||
# "hello world" 非中文、非大写、不居中 → text
|
||||
assert _detect_line_type("hello world", center_x_ratio=0.1) == "text"
|
||||
|
||||
def test_detect_uppercase_heading(self) -> None:
|
||||
"""全大写短行 → heading。"""
|
||||
assert _detect_line_type("INTRODUCTION") == "heading"
|
||||
|
||||
# ── 新增:编号变体 ────────────────────────────────
|
||||
|
||||
def test_detect_numbered_without_space(self) -> None:
|
||||
"""编号后无空格("1.内容")→ numbered。"""
|
||||
assert _detect_line_type("1.内容") == "numbered"
|
||||
assert _detect_line_type("1)内容") == "numbered"
|
||||
|
||||
def test_detect_numbered_parenthesized(self) -> None:
|
||||
"""括号编号 → numbered。"""
|
||||
assert _detect_line_type("(1) 内容") == "numbered"
|
||||
assert _detect_line_type("(2)内容") == "numbered"
|
||||
|
||||
def test_detect_numbered_chinese_numeral(self) -> None:
|
||||
"""中文数字序号 → numbered。"""
|
||||
assert _detect_line_type("① 第一项") == "numbered"
|
||||
assert _detect_line_type("②第二项") == "numbered"
|
||||
|
||||
# ── 新增:TOC 目录行 ──────────────────────────────
|
||||
|
||||
def test_detect_toc_line(self) -> None:
|
||||
"""TOC 目录行(含连续点号)→ numbered。"""
|
||||
assert _detect_line_type("第一章 .............. 1") == "numbered"
|
||||
assert _detect_line_type("1.1 概述 ............ 10") == "numbered"
|
||||
|
||||
# ── 新增:更多 bullet 变体 ────────────────────────
|
||||
|
||||
def test_detect_bullet_variants(self) -> None:
|
||||
"""更多 bullet 变体 → list_item。"""
|
||||
assert _detect_line_type("▪ 项目") == "list_item"
|
||||
assert _detect_line_type("▸ 子项") == "list_item"
|
||||
assert _detect_line_type("→ 链接") == "list_item"
|
||||
assert _detect_line_type("· 条目") == "list_item"
|
||||
|
||||
def test_detect_standalone_bullet(self) -> None:
|
||||
"""独立 bullet 字符 → list_item。"""
|
||||
assert _detect_line_type("•") == "list_item"
|
||||
assert _detect_line_type("▪") == "list_item"
|
||||
assert _detect_line_type("→") == "list_item"
|
||||
|
||||
# ── 新增:字体大小辅助标题检测 ────────────────────
|
||||
|
||||
def test_detect_larger_font_heading(self) -> None:
|
||||
"""大字(rel_height > 1.3)→ heading。"""
|
||||
assert _detect_line_type("这是一段长文本但字号大", rel_height=1.5) == "heading"
|
||||
# 中号字(rel_height < 1.3)且不居中 → text
|
||||
assert _detect_line_type("这是一段长文本字号普通", rel_height=1.1) == "text"
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
# 标题级别检测
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
class TestHeadingLevel:
|
||||
"""标题级别检测。"""
|
||||
|
||||
def test_h1_centered_large(self) -> None:
|
||||
assert _detect_heading_level(rel_height=1.5, is_centered=True) == 1
|
||||
|
||||
def test_h1_large_alone(self) -> None:
|
||||
assert _detect_heading_level(rel_height=1.8) == 1
|
||||
|
||||
def test_h2_large(self) -> None:
|
||||
assert _detect_heading_level(rel_height=1.4) == 2
|
||||
|
||||
def test_h2_centered(self) -> None:
|
||||
assert _detect_heading_level(is_centered=True) == 2
|
||||
|
||||
def test_h3_default(self) -> None:
|
||||
assert _detect_heading_level() == 3
|
||||
assert _detect_heading_level(rel_height=1.1) == 3
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
# sort_and_group
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
class TestSortAndGroup:
|
||||
"""阅读顺序排序 + 行归组。"""
|
||||
|
||||
def test_empty(self) -> None:
|
||||
assert sort_and_group([]) == []
|
||||
|
||||
def test_single_block(self) -> None:
|
||||
blocks = [_make_block("你好")]
|
||||
lines = sort_and_group(blocks)
|
||||
assert len(lines) == 1
|
||||
assert len(lines[0]) == 1
|
||||
assert lines[0][0].text == "你好"
|
||||
|
||||
def test_top_to_bottom_sort(self) -> None:
|
||||
"""按 Y 坐标从上到下排序。"""
|
||||
blocks = [
|
||||
_make_block("第二行", y1=100, y2=130),
|
||||
_make_block("第一行", y1=10, y2=40),
|
||||
]
|
||||
lines = sort_and_group(blocks)
|
||||
assert len(lines) == 2
|
||||
assert lines[0][0].text == "第一行"
|
||||
assert lines[1][0].text == "第二行"
|
||||
|
||||
def test_same_line_grouping(self) -> None:
|
||||
"""Y 坐标接近的 blocks 归为同一行,按 X 排序。"""
|
||||
blocks = [
|
||||
_make_block("右", x1=100, x2=130, y1=10, y2=30),
|
||||
_make_block("左", x1=0, x2=30, y1=10, y2=30),
|
||||
]
|
||||
lines = sort_and_group(blocks)
|
||||
assert len(lines) == 1
|
||||
assert len(lines[0]) == 2
|
||||
assert lines[0][0].text == "左"
|
||||
assert lines[0][1].text == "右"
|
||||
|
||||
def test_multi_line_grouping(self) -> None:
|
||||
"""多行正确归组。"""
|
||||
blocks = [
|
||||
_make_block("B1", x1=0, x2=30, y1=100, y2=130),
|
||||
_make_block("A1", x1=0, x2=30, y1=10, y2=40),
|
||||
_make_block("A2", x1=40, x2=70, y1=10, y2=40),
|
||||
]
|
||||
lines = sort_and_group(blocks)
|
||||
assert len(lines) == 2
|
||||
# 第一行:A1、A2
|
||||
assert len(lines[0]) == 2
|
||||
assert lines[0][0].text == "A1"
|
||||
assert lines[0][1].text == "A2"
|
||||
# 第二行:B1
|
||||
assert len(lines[1]) == 1
|
||||
assert lines[1][0].text == "B1"
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
# 多栏检测
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
class TestMultiColumn:
|
||||
"""多栏检测。"""
|
||||
|
||||
def assert_one_column(self, blocks: list, page_width: float = 800) -> None:
|
||||
cols = _detect_columns(blocks, page_width)
|
||||
assert len(cols) == 1, f"应检测为单栏,实际 {len(cols)} 栏"
|
||||
|
||||
def assert_two_columns(self, blocks: list, page_width: float = 800) -> None:
|
||||
cols = _detect_columns(blocks, page_width)
|
||||
assert len(cols) == 2, f"应检测为双栏,实际 {len(cols)} 栏"
|
||||
assert len(cols[0]) > 0
|
||||
assert len(cols[1]) > 0
|
||||
|
||||
def test_single_column_few_blocks(self) -> None:
|
||||
"""少于 4 个 block 不触发多栏检测。"""
|
||||
self.assert_one_column([_make_block("A")])
|
||||
|
||||
def test_single_column_no_gap(self) -> None:
|
||||
"""无间隙 → 单栏。"""
|
||||
blocks = [
|
||||
_make_block("左1", x1=0, x2=100, y1=10, y2=30),
|
||||
_make_block("右1", x1=100, x2=200, y1=10, y2=30),
|
||||
_make_block("左2", x1=0, x2=100, y1=40, y2=60),
|
||||
_make_block("右2", x1=100, x2=200, y1=40, y2=60),
|
||||
]
|
||||
self.assert_one_column(blocks)
|
||||
|
||||
def test_two_columns_clear_gap(self) -> None:
|
||||
"""有明显水平间隙 → 双栏。"""
|
||||
blocks = [
|
||||
_make_block("左栏 A", x1=0, x2=150, y1=10, y2=30),
|
||||
_make_block("左栏 B", x1=0, x2=150, y1=40, y2=60),
|
||||
_make_block("右栏 A", x1=500, x2=650, y1=15, y2=35),
|
||||
_make_block("右栏 B", x1=500, x2=650, y1=50, y2=70),
|
||||
]
|
||||
self.assert_two_columns(blocks, page_width=800)
|
||||
|
||||
def test_sort_and_group_two_columns(self) -> None:
|
||||
"""双栏排版:左栏两行,右栏两行。"""
|
||||
blocks = [
|
||||
_make_block("右A", x1=500, x2=650, y1=50, y2=70),
|
||||
_make_block("左B", x1=0, x2=150, y1=40, y2=60),
|
||||
_make_block("左A", x1=0, x2=150, y1=10, y2=30),
|
||||
_make_block("右B", x1=500, x2=650, y1=80, y2=100),
|
||||
]
|
||||
lines = sort_and_group(blocks, page_width=800)
|
||||
# 左栏两行 → 右栏两行
|
||||
assert len(lines) == 4
|
||||
# 左栏第一行
|
||||
assert lines[0][0].text == "左A"
|
||||
# 左栏第二行
|
||||
assert lines[1][0].text == "左B"
|
||||
# 右栏第一行
|
||||
assert lines[2][0].text == "右A"
|
||||
# 右栏第二行
|
||||
assert lines[3][0].text == "右B"
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
# group_paragraphs
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
class TestGroupParagraphs:
|
||||
"""段落分组。"""
|
||||
|
||||
def test_empty(self) -> None:
|
||||
assert group_paragraphs([]) == []
|
||||
|
||||
def test_single_para(self) -> None:
|
||||
"""多行同段落(用长文本避免触发标题检测)。"""
|
||||
lines = [
|
||||
[_make_block("第一行正文内容这里是长文本")],
|
||||
[_make_block("第二行正文内容再接一些字")],
|
||||
]
|
||||
paras = group_paragraphs(lines)
|
||||
assert len(paras) == 1
|
||||
assert paras[0]["type"] == "text"
|
||||
assert paras[0]["type"] == "text"
|
||||
|
||||
def test_paragraph_break_by_gap(self) -> None:
|
||||
"""行间距大 → 分段(需要 3+ 行,单行间距无法超过自身中位值的 1.8 倍)。"""
|
||||
lines = [
|
||||
[_make_block("第一段正文在这里第一行啊", y1=10, y2=30)],
|
||||
[_make_block("第一段正文在这里第二行", y1=40, y2=60)],
|
||||
[_make_block("第二段正文这里间距很大", y1=200, y2=230)],
|
||||
]
|
||||
paras = group_paragraphs(lines, page_width=800)
|
||||
assert len(paras) == 2
|
||||
# 第一段:两行
|
||||
assert paras[0]["type"] == "text"
|
||||
assert "第一段正文在这里" in paras[0]["text"]
|
||||
# 第二段:一行
|
||||
assert "第二段正文" in paras[1]["text"]
|
||||
|
||||
def test_heading_paragraph(self) -> None:
|
||||
"""短行 → 标题段。"""
|
||||
lines = [[_make_block("第一章")], [_make_block("正文内容")]]
|
||||
paras = group_paragraphs(lines)
|
||||
assert len(paras) >= 1
|
||||
# 标题段
|
||||
assert paras[0]["type"] == "heading"
|
||||
|
||||
def test_list_paragraph(self) -> None:
|
||||
"""列表项段落。"""
|
||||
lines = [
|
||||
[_make_block("- 项目一", y1=10, y2=30)],
|
||||
[_make_block("- 项目二", y1=40, y2=60)],
|
||||
]
|
||||
paras = group_paragraphs(lines)
|
||||
assert len(paras) == 1
|
||||
assert paras[0]["type"] == "list_item"
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
# build_markdown_text
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
class TestBuildMarkdown:
|
||||
"""完整的 Markdown 转换。"""
|
||||
|
||||
def test_empty(self) -> None:
|
||||
md, lines, paras = build_markdown_text([])
|
||||
assert md == ""
|
||||
assert lines == []
|
||||
assert paras == []
|
||||
|
||||
def test_simple_text(self) -> None:
|
||||
"""简单段落转换。"""
|
||||
blocks = [
|
||||
_make_block("你好世界"),
|
||||
]
|
||||
md, lines, paras = build_markdown_text(blocks)
|
||||
assert "你好世界" in md
|
||||
|
||||
def test_heading_conversion(self) -> None:
|
||||
"""标题应加 # 前缀(级别基于字体大小)。"""
|
||||
# 标题 block 更大 → 推断为 h2
|
||||
blocks = [
|
||||
_make_block("第一章", x1=0, x2=100, y1=10, y2=50), # 40px 高
|
||||
_make_block("正文内容长一些避免被检测为标题", x1=0, x2=200, y1=60, y2=80), # 20px 高
|
||||
]
|
||||
md, lines, paras = build_markdown_text(blocks)
|
||||
assert "### 第一章" in md or "## 第一章" in md
|
||||
assert "正文内容长一些避免被检测为标题" in md
|
||||
|
||||
def test_list_conversion(self) -> None:
|
||||
"""列表项应加 - 前缀。"""
|
||||
blocks = [
|
||||
_make_block("- 苹果"),
|
||||
_make_block("- 香蕉"),
|
||||
]
|
||||
md, lines, paras = build_markdown_text(blocks)
|
||||
assert "- 苹果" in md
|
||||
assert "- 香蕉" in md
|
||||
|
||||
def test_centered_heading(self) -> None:
|
||||
"""居中的短行 → heading。"""
|
||||
# 中心 X 在页面宽度中间
|
||||
blocks = [
|
||||
_make_block("欢迎光临", x1=300, x2=500, y1=10, y2=40),
|
||||
]
|
||||
md, lines, paras = build_markdown_text(blocks, page_width=800)
|
||||
assert "欢迎光临" in md
|
||||
|
||||
def test_heading_level_h1(self) -> None:
|
||||
"""居中大字 → h1(# 前缀)。"""
|
||||
blocks = [
|
||||
_make_block("年度报告", x1=300, x2=500, y1=10, y2=50),
|
||||
_make_block("正文内容较小", x1=0, x2=100, y1=60, y2=75),
|
||||
]
|
||||
md, lines, paras = build_markdown_text(blocks, page_width=800)
|
||||
assert "# 年度报告" in md or "## 年度报告" in md
|
||||
|
||||
def test_heading_level_h3(self) -> None:
|
||||
"""等大字体短行 → h3(### 前缀)。"""
|
||||
blocks = [
|
||||
_make_block("摘要", x1=0, x2=100, y1=10, y2=30),
|
||||
_make_block("第一章内容文字", x1=0, x2=100, y1=40, y2=70),
|
||||
]
|
||||
md, lines, paras = build_markdown_text(blocks, page_width=800)
|
||||
# "摘要" ≤ 8 字无标点 → heading, 但字体与正文同大 → h3
|
||||
assert "### 摘要" in md
|
||||
|
||||
def test_separator_conversion(self) -> None:
|
||||
"""分隔线 → ---。"""
|
||||
blocks = [
|
||||
_make_block("---"),
|
||||
]
|
||||
md, lines, paras = build_markdown_text(blocks)
|
||||
assert "---" in md
|
||||
|
||||
def test_multi_line_same_para(self) -> None:
|
||||
"""多行近距离内容合并在一个段落(长文本避免标题检测)。"""
|
||||
blocks = [
|
||||
_make_block("第一行长文本避免被检测为标题行", y1=10, y2=30),
|
||||
_make_block("第二行避免被检测为标题", y1=35, y2=55),
|
||||
]
|
||||
md, lines, paras = build_markdown_text(blocks)
|
||||
assert "第一行长文本避免被检测为标题行" in md
|
||||
assert "第二行避免被检测为标题" in md
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
# 边缘情况
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
class TestEdgeCases:
|
||||
"""边缘情况。"""
|
||||
|
||||
def test_single_block_only(self) -> None:
|
||||
blocks = [_make_block("Hello")]
|
||||
md, lines, paras = build_markdown_text(blocks)
|
||||
assert md == "Hello"
|
||||
|
||||
def test_blocks_with_same_y(self) -> None:
|
||||
"""完全同一 Y 位置的多个 blocks(罕见但需处理)。"""
|
||||
blocks = [
|
||||
_make_block("A", x1=0, x2=30, y1=10, y2=30),
|
||||
_make_block("B", x1=40, x2=70, y1=10, y2=30),
|
||||
]
|
||||
lines = sort_and_group(blocks)
|
||||
assert len(lines) == 1
|
||||
assert len(lines[0]) == 2
|
||||
|
||||
def test_blocks_with_negligible_height(self) -> None:
|
||||
"""高度为 0 的 block 不应崩。"""
|
||||
block = TextBlock(
|
||||
text="边缘",
|
||||
confidence=0.5,
|
||||
bbox=BBox(points=[[0, 0], [0, 0], [0, 0], [0, 0]]),
|
||||
)
|
||||
assert _block_height(block) == 0.0
|
||||
lines = sort_and_group([block])
|
||||
assert len(lines) == 1
|
||||
|
||||
def test_confidence_range(self) -> None:
|
||||
"""置信度在 0~1 范围内。"""
|
||||
block = _make_block("test", confidence=1.0)
|
||||
assert 0 <= block.confidence <= 1
|
||||
|
||||
def test_long_line_content(self) -> None:
|
||||
"""长行(> 40 字符)→ text 而非 heading。"""
|
||||
long_text = "A" * 50
|
||||
blocks = [_make_block(long_text)]
|
||||
md, lines, paras = build_markdown_text(blocks)
|
||||
assert long_text in md
|
||||
assert "##" not in md
|
||||
|
||||
def test_empty_text_blocks(self) -> None:
|
||||
"""空文本 block 不应造成影响。"""
|
||||
blocks = [
|
||||
_make_block(""),
|
||||
_make_block("实际内容"),
|
||||
]
|
||||
md, lines, paras = build_markdown_text(blocks)
|
||||
assert "实际内容" in md
|
||||
|
||||
def test_numeric_only(self) -> None:
|
||||
"""纯数字行 → numbered。"""
|
||||
blocks = [_make_block("42")]
|
||||
# 短行数字 → numbered(先于 heading 触发)
|
||||
md, lines, paras = build_markdown_text(blocks)
|
||||
assert "42" in md
|
||||
Reference in new issue
Block a user