document_content_service.py 9.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278
  1. """不依赖AI或Office进程的PDF/DOCX正文提取与检索文本构建。"""
  2. from __future__ import annotations
  3. import logging
  4. import re
  5. from dataclasses import dataclass
  6. from datetime import datetime, timezone
  7. from pathlib import Path
  8. import fitz
  9. from docx import Document as WordDocument
  10. from dms.common.enums import ContentExtractStatus
  11. logger = logging.getLogger(__name__)
  12. @dataclass(frozen=True, slots=True)
  13. class ContentExtraction:
  14. text: str | None
  15. status: str
  16. extracted_at: datetime | None
  17. # 提取失败原因(脱敏:不含绝对路径,仅异常类型与首行信息),供管理员排障
  18. error: str | None = None
  19. _ERROR_MAX_LENGTH = 480
  20. def sanitize_extract_error(exc: Exception) -> str:
  21. message = str(exc).splitlines()[0] if str(exc).strip() else ""
  22. # 去掉可能的绝对路径(Windows 盘符 / POSIX 根路径),避免泄露部署目录
  23. message = re.sub(r"[A-Za-z]:[\\/][^\s,;]*", "<path>", message)
  24. message = re.sub(r"(?<![\w])/[A-Za-z0-9_./-]+", "<path>", message)
  25. text = f"{type(exc).__name__}: {message}" if message else type(exc).__name__
  26. return text[:_ERROR_MAX_LENGTH]
  27. def build_search_text(
  28. name: str,
  29. summary: str | None,
  30. tags: list[str] | None,
  31. content_text: str | None,
  32. ) -> str:
  33. return " ".join(
  34. part for part in [name, summary or "", *(tags or []), content_text or ""]
  35. if part
  36. ).strip()
  37. def _word_text(path: Path) -> str:
  38. document = WordDocument(path)
  39. values: list[str] = []
  40. def add_paragraphs(paragraphs) -> None:
  41. values.extend(
  42. paragraph.text.strip()
  43. for paragraph in paragraphs
  44. if paragraph.text.strip()
  45. )
  46. def add_tables(tables) -> None:
  47. for table in tables:
  48. for row in table.rows:
  49. for cell in row.cells:
  50. add_paragraphs(cell.paragraphs)
  51. add_tables(cell.tables)
  52. add_paragraphs(document.paragraphs)
  53. add_tables(document.tables)
  54. for section in document.sections:
  55. add_paragraphs(section.header.paragraphs)
  56. add_tables(section.header.tables)
  57. add_paragraphs(section.footer.paragraphs)
  58. add_tables(section.footer.tables)
  59. return "\n".join(values).strip()
  60. # 块内相邻行主字号相对差达到该比例时视为段落边界(标题与正文同块的兜底)
  61. _FONT_SIZE_SPLIT_RATIO = 0.2
  62. def _line_main_size(line) -> float:
  63. """取行内最大 span 字号作为该行主字号。"""
  64. return max(
  65. (span["size"] for span in line["spans"] if span["text"].strip()),
  66. default=0.0,
  67. )
  68. # 退化版面判定:逐行等距网格的间距容差(pt)与字号容差(pt)
  69. _DEGENERATE_GAP_TOLERANCE = 2.0
  70. _DEGENERATE_SIZE_TOLERANCE = 0.5
  71. def _pdf_page_text(page) -> str:
  72. """按版面块识别段落:块边界即段落边界,块内合并软换行。
  73. PyMuPDF 按版面邻近度将行分组为块,标题/落款等因字号或间距不同独立成块,
  74. 无需依赖标点猜测即可与正文分开。块内沿用启发式合并(句末标点/条目编号切分,
  75. 相邻行字号突变亦视为段落边界)。整页为逐行等距同字号的网格(如扫描件文本层
  76. 逐行定位)时块结构无分组价值,回退纯文本启发式。
  77. """
  78. data = page.get_text("dict", flags=fitz.TEXTFLAGS_TEXT)
  79. # 每块:(行列表[(文本, 主字号)], bbox顶, bbox底)
  80. blocks: list[tuple[list[tuple[str, float]], float, float]] = []
  81. line_count = 0
  82. for block in data["blocks"]:
  83. if block.get("type") != 0:
  84. continue
  85. items = [
  86. (text, _line_main_size(line))
  87. for line in block["lines"]
  88. if (text := "".join(span["text"] for span in line["spans"]).strip())
  89. ]
  90. if items:
  91. blocks.append((items, block["bbox"][1], block["bbox"][3]))
  92. line_count += len(items)
  93. if line_count == 0:
  94. return ""
  95. if _is_degenerate_line_grid(blocks, line_count):
  96. return _merge_wrapped_lines(page.get_text("text"))
  97. paragraphs: list[str] = []
  98. for items, _, _ in blocks:
  99. paragraphs.extend(_merge_line_items(items))
  100. return "\n".join(paragraphs)
  101. def _is_degenerate_line_grid(
  102. blocks: list[tuple[list[tuple[str, float]], float, float]],
  103. line_count: int,
  104. ) -> bool:
  105. """判断整页是否为"每行一块、等距同字号"的退化网格(扫描件文本层典型特征)。
  106. 需同时满足:每块仅一行、块数≥3(至少两个间距可比较均匀性)、
  107. 相邻块间距一致、各行字号一致。标题+正文等少量块或间距/字号有变化的
  108. 页面信任版面块结构,不视为退化。
  109. """
  110. if len(blocks) < 3 or len(blocks) < line_count:
  111. return False
  112. if any(len(items) != 1 for items, _, _ in blocks):
  113. return False
  114. gaps = [
  115. blocks[index + 1][1] - top for index, (_, top, _) in enumerate(blocks[:-1])
  116. ]
  117. if max(gaps) - min(gaps) > _DEGENERATE_GAP_TOLERANCE:
  118. return False
  119. sizes = [items[0][1] for items, _, _ in blocks]
  120. return max(sizes) - min(sizes) <= _DEGENERATE_SIZE_TOLERANCE
  121. def _pdf_text(path: Path) -> str:
  122. with fitz.open(stream=path.read_bytes(), filetype="pdf") as document:
  123. pages = [_pdf_page_text(page) for page in document]
  124. # 页与页之间以空行分隔(页边界即段落边界)
  125. return "\n\n".join(page for page in pages if page).strip()
  126. # 句末标点:一行以此结尾视为自然段落结束,不再与下一行合并
  127. _SENTENCE_END_RE = re.compile(r"[。!?!?;;::…..\"”’」』))\]}>》]+$")
  128. # 条目/章节起始标记:下一行以此开头视为新段落(如 "1." "(一)" "第2章" "一、")
  129. _LIST_START_RE = re.compile(
  130. r"^(?:"
  131. r"\d{1,3}[.、))]" # 1. / 1、 / 1)
  132. r"|[((][一二三四五六七八九十百\d]{1,4}[))]" # (一) / (1)
  133. r"|[一二三四五六七八九十]{1,3}、" # 一、
  134. r"|第[一二三四五六七八九十百千\d]{1,5}[章节条部分篇]" # 第3章 / 第五节
  135. r")"
  136. )
  137. def _join_lines(previous: str, following: str) -> str:
  138. """合并段内被换行符断开的两行:两侧均为 ASCII 字母数字时补空格,其余直接相连。"""
  139. if previous and following and previous[-1].isascii() and previous[-1].isalnum() \
  140. and following[0].isascii() and following[0].isalnum():
  141. return f"{previous} {following}"
  142. return previous + following
  143. def _merge_line_items(items: list[tuple[str, float]]) -> list[str]:
  144. """将一组行(文本、主字号)合并为段落列表。
  145. 新段落(切分)的情况:
  146. 1. 下一行以条目/章节编号开头;
  147. 2. 相邻行主字号相对差 ≥ _FONT_SIZE_SPLIT_RATIO(标题与正文同块的兜底,
  148. 字号为 0 表示无版面信息,不参与比较);
  149. 3. 当前缓冲以句末标点结尾(在追加该行后立即收尾)。
  150. 其余情况视为段内软换行,按 _join_lines 规则合并。
  151. """
  152. paragraphs: list[str] = []
  153. buffer = ""
  154. previous_size = 0.0
  155. for text, size in items:
  156. if buffer and _LIST_START_RE.match(text):
  157. paragraphs.append(buffer)
  158. buffer = text
  159. elif (
  160. buffer
  161. and previous_size
  162. and size
  163. and abs(size - previous_size) / max(size, previous_size)
  164. >= _FONT_SIZE_SPLIT_RATIO
  165. ):
  166. paragraphs.append(buffer)
  167. buffer = text
  168. elif buffer:
  169. buffer = _join_lines(buffer, text)
  170. else:
  171. buffer = text
  172. previous_size = size
  173. if buffer and _SENTENCE_END_RE.search(buffer):
  174. paragraphs.append(buffer)
  175. buffer = ""
  176. if buffer:
  177. paragraphs.append(buffer)
  178. return paragraphs
  179. def _merge_wrapped_lines(page_text: str) -> str:
  180. """纯文本兜底:PDF 复制文本时会在段内软换行处插入 \\n,此处按启发式规则合并。
  181. 换行保留(视为新段落)的情况:
  182. 1. 空行(原始段落边界);
  183. 2. 上一行以句末标点结尾;
  184. 3. 下一行以条目/章节编号开头。
  185. 其余情况视为段内软换行,直接合并(中文不加空格,英文单词间补一个空格)。
  186. """
  187. paragraphs: list[str] = []
  188. group: list[tuple[str, float]] = []
  189. for raw_line in page_text.splitlines():
  190. line = raw_line.strip()
  191. if not line:
  192. paragraphs.extend(_merge_line_items(group))
  193. group = []
  194. else:
  195. group.append((line, 0.0))
  196. paragraphs.extend(_merge_line_items(group))
  197. return "\n".join(paragraphs)
  198. def extract_document_content(path: Path, extension: str) -> ContentExtraction:
  199. normalized = extension.lower().lstrip(".")
  200. if normalized not in {"pdf", "docx"}:
  201. return ContentExtraction(
  202. text=None,
  203. status=ContentExtractStatus.UNSUPPORTED.value,
  204. extracted_at=None,
  205. )
  206. extracted_at = datetime.now(timezone.utc).replace(tzinfo=None)
  207. try:
  208. text = _pdf_text(path) if normalized == "pdf" else _word_text(path)
  209. except Exception as exc:
  210. logger.exception("文档正文提取失败:extension=%s", normalized)
  211. return ContentExtraction(
  212. text=None,
  213. status=ContentExtractStatus.FAILED.value,
  214. extracted_at=extracted_at,
  215. error=sanitize_extract_error(exc),
  216. )
  217. return ContentExtraction(
  218. text=text or None,
  219. status=(
  220. ContentExtractStatus.SUCCESS.value
  221. if text
  222. else ContentExtractStatus.EMPTY.value
  223. ),
  224. extracted_at=extracted_at,
  225. error=None,
  226. )
  227. __all__ = [
  228. "ContentExtraction",
  229. "build_search_text",
  230. "extract_document_content",
  231. "sanitize_extract_error",
  232. ]