test_document_content_service.py 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232
  1. """正文提取服务(document_content_service)纯单元测试。
  2. 不依赖数据库与请求上下文:
  3. - _merge_wrapped_lines:PDF 段内软换行合并启发式(纯文本兜底);
  4. - _merge_line_items:块内合并规则(含字号突变切分);
  5. - sanitize_extract_error:失败原因脱敏(去除绝对路径);
  6. - extract_document_content:UNSUPPORTED/FAILED 分支、PDF 版面块分段与退化回退。
  7. """
  8. from __future__ import annotations
  9. from pathlib import Path
  10. import fitz
  11. from dms.common.enums import ContentExtractStatus
  12. from dms.services.document_content_service import (
  13. _merge_line_items,
  14. _merge_wrapped_lines,
  15. extract_document_content,
  16. sanitize_extract_error,
  17. )
  18. def test_merge_chinese_wrapped_line_without_space():
  19. text = "这是一段在段内被换行\n打断的中文正文,合并后不应有空格"
  20. assert _merge_wrapped_lines(text) == "这是一段在段内被换行打断的中文正文,合并后不应有空格"
  21. def test_merge_english_words_with_space():
  22. text = "emergency response\nplan continues here"
  23. assert _merge_wrapped_lines(text) == "emergency response plan continues here"
  24. def test_sentence_end_punctuation_keeps_paragraph_break():
  25. text = "第一条意见如下。\n第二条意见如下:\n具体内容见附件。"
  26. assert _merge_wrapped_lines(text) == "第一条意见如下。\n第二条意见如下:\n具体内容见附件。"
  27. def test_blank_line_keeps_paragraph_break():
  28. text = "第一段未结束\n仍在同一段\n\n第二段开始"
  29. assert _merge_wrapped_lines(text) == "第一段未结束仍在同一段\n第二段开始"
  30. def test_list_marker_start_keeps_paragraph_break():
  31. text = "总体要求如下\n1. 第一条\n(一)细分条款\n一、中文序号\n第2章范围"
  32. assert _merge_wrapped_lines(text) == "总体要求如下\n1. 第一条\n(一)细分条款\n一、中文序号\n第2章范围"
  33. def test_mixed_cjk_and_ascii_merge_directly():
  34. # 一侧为中文时不补空格
  35. text = "使用 PDF\n格式存储文件"
  36. assert _merge_wrapped_lines(text) == "使用 PDF格式存储文件"
  37. def test_sanitize_error_strips_windows_path():
  38. exc = RuntimeError("cannot open D:/dms/storage/uploads/a/b.pdf: permission denied")
  39. sanitized = sanitize_extract_error(exc)
  40. assert sanitized.startswith("RuntimeError:")
  41. assert "D:/" not in sanitized
  42. assert "<path>" in sanitized
  43. def test_sanitize_error_strips_posix_path_and_truncates():
  44. exc = ValueError("failed at /var/lib/dms/storage/file.pdf " + "x" * 600)
  45. sanitized = sanitize_extract_error(exc)
  46. assert "/var/lib" not in sanitized
  47. assert len(sanitized) <= 480
  48. def test_sanitize_error_multiline_uses_first_line():
  49. exc = RuntimeError("第一行原因\n第二行堆栈")
  50. assert sanitize_extract_error(exc) == "RuntimeError: 第一行原因"
  51. def test_unsupported_extension_returns_unsupported(tmp_path: Path):
  52. target = tmp_path / "image.png"
  53. target.write_bytes(b"\x89PNG\r\n\x1a\n")
  54. result = extract_document_content(target, "png")
  55. assert result.status == ContentExtractStatus.UNSUPPORTED.value
  56. assert result.text is None
  57. assert result.error is None
  58. assert result.extracted_at is None
  59. def test_corrupt_pdf_returns_failed_with_error(tmp_path: Path):
  60. target = tmp_path / "broken.pdf"
  61. target.write_bytes(b"not a real pdf content")
  62. result = extract_document_content(target, "pdf")
  63. assert result.status == ContentExtractStatus.FAILED.value
  64. assert result.text is None
  65. assert result.error # 必须携带失败原因
  66. assert result.extracted_at is not None
  67. def _write_pdf(path: Path, lines: list[str], gap: int = 16, fontsize: int = 11) -> None:
  68. document = fitz.open()
  69. page = document.new_page()
  70. y = 72
  71. for line in lines:
  72. # china-s:PyMuPDF 内嵌简体中文字体(默认 helv 无法编码 CJK)
  73. page.insert_text((72, y), line, fontsize=fontsize, fontname="china-s")
  74. y += gap
  75. document.save(path)
  76. document.close()
  77. def test_pdf_extraction_merges_wrapped_lines(tmp_path: Path):
  78. target = tmp_path / "wrapped.pdf"
  79. _write_pdf(
  80. target,
  81. ["这是一段在段内被换行", "打断的中文正文。", "另一段独立内容。"],
  82. )
  83. result = extract_document_content(target, "pdf")
  84. assert result.status == ContentExtractStatus.SUCCESS.value
  85. assert result.error is None
  86. assert result.text is not None
  87. # 第一行未以句末标点结尾:与第二行合并;第二行以"。"结尾:保留段落边界
  88. assert "这是一段在段内被换行打断的中文正文。" in result.text
  89. assert "打断的中文正文。\n另一段独立内容。" in result.text
  90. def test_pdf_heading_in_separate_block_not_merged_with_body(tmp_path: Path):
  91. """标题行与正文行距较大(独立成块)时,即使标题无句末标点也不并入正文。"""
  92. target = tmp_path / "heading.pdf"
  93. document = fitz.open()
  94. page = document.new_page()
  95. page.insert_text((72, 72), "第一章 总则", fontsize=11, fontname="china-s")
  96. page.insert_text((72, 72 + 24), "为规范方案管理,特制定本办法。", fontsize=11, fontname="china-s")
  97. document.save(target)
  98. document.close()
  99. result = extract_document_content(target, "pdf")
  100. assert result.status == ContentExtractStatus.SUCCESS.value
  101. assert result.text is not None
  102. assert "第一章 总则\n为规范方案管理,特制定本办法。" in result.text
  103. def test_pdf_different_font_size_splits_paragraph(tmp_path: Path):
  104. """标题与正文字号不同时独立成块(或块内字号突变切分),不合并为一段。"""
  105. target = tmp_path / "fontsize.pdf"
  106. document = fitz.open()
  107. page = document.new_page()
  108. page.insert_text((72, 72), "方案名称", fontsize=18, fontname="china-s")
  109. page.insert_text((72, 72 + 24), "本方案用于说明字号差异的段落切分", fontsize=11, fontname="china-s")
  110. document.save(target)
  111. document.close()
  112. result = extract_document_content(target, "pdf")
  113. assert result.status == ContentExtractStatus.SUCCESS.value
  114. assert result.text is not None
  115. assert "方案名称\n本方案用于说明字号差异的段落切分" in result.text
  116. def test_pdf_degenerate_layout_falls_back_to_heuristic(tmp_path: Path):
  117. """每行独立成块(行距≥20pt 逐行定位)时回退纯文本启发式,行为与现状一致。"""
  118. target = tmp_path / "degenerate.pdf"
  119. _write_pdf(
  120. target,
  121. ["这是一段在段内被换行", "打断的中文正文。", "另一段独立内容。"],
  122. gap=24,
  123. )
  124. result = extract_document_content(target, "pdf")
  125. assert result.status == ContentExtractStatus.SUCCESS.value
  126. assert result.text is not None
  127. assert "这是一段在段内被换行打断的中文正文。" in result.text
  128. assert "打断的中文正文。\n另一段独立内容。" in result.text
  129. def test_merge_line_items_splits_on_font_size_jump():
  130. items = [("方案名称", 18.0, ""), ("本方案用于说明字号差异", 11.0, "")]
  131. assert _merge_line_items(items) == ["方案名称", "本方案用于说明字号差异"]
  132. def test_merge_line_items_merges_same_font_size():
  133. items = [("段内被换行", 11.0, "fangsong"), ("打断的中文正文", 11.0, "fangsong")]
  134. assert _merge_line_items(items) == ["段内被换行打断的中文正文"]
  135. def test_merge_line_items_ignores_font_size_when_unavailable():
  136. # 字号为 0(纯文本兜底路径)不参与字号比较,仍按启发式合并
  137. items = [("段内被换行", 0.0, ""), ("打断的中文正文", 0.0, "")]
  138. assert _merge_line_items(items) == ["段内被换行打断的中文正文"]
  139. def test_merge_line_items_splits_on_font_family_change():
  140. # 公文字号体系:黑体标题与仿宋正文同字号,仅家族不同
  141. items = [("一、总体要求", 16.0, "simhei"), ("为保证方案落地,制定本要求。", 16.0, "fangsong")]
  142. assert _merge_line_items(items) == ["一、总体要求", "为保证方案落地,制定本要求。"]
  143. def test_merge_line_items_splits_on_bold_change():
  144. # 同家族仅加粗不同(仿宋加粗三级标题 → 仿宋正文)
  145. items = [("主要职责范围", 16.0, "fangsong|b"), ("各成员单位按照职责分工落实。", 16.0, "fangsong")]
  146. assert _merge_line_items(items) == ["主要职责范围", "各成员单位按照职责分工落实。"]
  147. def test_merge_line_items_ignores_font_when_unavailable():
  148. # 字体为空(纯文本兜底路径)不参与字体比较,仍按启发式合并
  149. items = [("段内被换行", 11.0, ""), ("打断的中文正文", 11.0, "")]
  150. assert _merge_line_items(items) == ["段内被换行打断的中文正文"]
  151. def test_merge_line_items_keeps_single_char_symbol_with_following_text():
  152. # 前一行为单字符项目符号"•"(SymbolMT):不可能是标题,字体差异不切分
  153. items = [("•", 11.0, "symbolmt"), ("组织机构;", 11.0, "dengxian")]
  154. assert _merge_line_items(items) == ["•组织机构;"]
  155. def test_merge_line_items_splits_when_current_line_single_char():
  156. # 前一行为多字符标题、当前行为单字符(如表头"周"):字体差异仍应切分
  157. items = [("表1 工作计划", 12.0, "simhei"), ("周", 10.6, "calibri|b")]
  158. assert _merge_line_items(items) == ["表1 工作计划", "周"]
  159. def test_font_signature_normalization():
  160. from dms.services.document_content_service import _font_signature
  161. # 子集前缀剥离 + 风格 token 剥离 + 加粗位保留
  162. assert _font_signature("ABCDEE+Calibri-Bold", 0) == "calibri|b"
  163. assert _font_signature("ABCDEE+Calibri-Bold", 16) == "calibri|b"
  164. assert _font_signature("SimHei", 0) == "simhei"
  165. assert _font_signature("DengXian", 16) == "dengxian|b"
  166. assert _font_signature("", 0) == ""
  167. def test_list_start_matches_fullwidth_number_and_period():
  168. from dms.services.document_content_service import _LIST_START_RE
  169. assert _LIST_START_RE.match("1.主要任务")
  170. assert _LIST_START_RE.match("1.主要任务")
  171. assert _LIST_START_RE.match("(2)保障措施")
  172. assert _LIST_START_RE.match("第3章组织指挥")
  173. assert not _LIST_START_RE.match("2025 年度工作安排")