""" 布局后处理单元测试(纯逻辑,不依赖引擎和服务)。 测试 `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