document_content_service.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329
  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. # (标题与正文同块的兜底:公文字号体系下黑体/楷体标题与仿宋正文同字号,
  62. # 仅字体家族或加粗不同,字号差信号失灵,需字体信号互补)
  63. _FONT_SIZE_SPLIT_RATIO = 0.2
  64. # 字体名中的风格 token:剥离后不参与家族比较(加粗位单独保留在签名中)
  65. _FONT_STYLE_TOKENS = {"bold", "italic", "oblique"}
  66. def _font_signature(name: str, flags: int) -> str:
  67. """字体签名:剥离子集前缀与风格 token 的家族名(小写)+ 加粗位。
  68. 返回空串表示无字体信息(不参与相邻行字体比较)。
  69. """
  70. cleaned = re.sub(r"^[A-Za-z]{6}\+", "", name or "")
  71. parts = [part for part in re.split(r"[-,]", cleaned) if part]
  72. family = "".join(
  73. part for part in parts if part.lower() not in _FONT_STYLE_TOKENS
  74. ).lower()
  75. if not family:
  76. return ""
  77. bold = bool(flags & 16) or any(part.lower() == "bold" for part in parts)
  78. return f"{family}|b" if bold else family
  79. def _line_style(line) -> tuple[float, str]:
  80. """行主样式:(行内最大字号, 行内占非空白字符数最多的字体签名)。
  81. 主字体按字符数加权取众数而非取首 span,避免行首少量西文数字
  82. (与中文不同字体嵌入)导致正文的行主字体来回漂移。
  83. """
  84. main_size = 0.0
  85. weights: dict[str, int] = {}
  86. for span in line["spans"]:
  87. text = span["text"].strip()
  88. if not text:
  89. continue
  90. main_size = max(main_size, span["size"])
  91. signature = _font_signature(span["font"], span["flags"])
  92. if signature:
  93. weights[signature] = weights.get(signature, 0) + len(text)
  94. dominant = max(weights, key=weights.get) if weights else ""
  95. return main_size, dominant
  96. # 退化版面判定:逐行等距网格的间距容差(pt)与字号容差(pt)
  97. _DEGENERATE_GAP_TOLERANCE = 2.0
  98. _DEGENERATE_SIZE_TOLERANCE = 0.5
  99. def _pdf_page_text(page) -> str:
  100. """按版面块识别段落:块边界即段落边界,块内合并软换行。
  101. PyMuPDF 按版面邻近度将行分组为块,标题/落款等因字号或间距不同独立成块,
  102. 无需依赖标点猜测即可与正文分开。块内沿用启发式合并(句末标点/条目编号切分,
  103. 相邻行字号突变或主字体突变亦视为段落边界——公文标题与正文常同字号、
  104. 仅字体家族或加粗不同)。整页为逐行等距同字号的网格(如扫描件文本层
  105. 逐行定位)时块结构无分组价值,回退纯文本启发式。
  106. """
  107. data = page.get_text("dict", flags=fitz.TEXTFLAGS_TEXT)
  108. # 每块:(行列表[(文本, 主字号, 主字体签名)], bbox顶, bbox底)
  109. blocks: list[tuple[list[tuple[str, float, str]], float, float]] = []
  110. line_count = 0
  111. for block in data["blocks"]:
  112. if block.get("type") != 0:
  113. continue
  114. items: list[tuple[str, float, str]] = []
  115. for line in block["lines"]:
  116. text = "".join(span["text"] for span in line["spans"]).strip()
  117. if not text:
  118. continue
  119. size, font = _line_style(line)
  120. items.append((text, size, font))
  121. if items:
  122. blocks.append((items, block["bbox"][1], block["bbox"][3]))
  123. line_count += len(items)
  124. if line_count == 0:
  125. return ""
  126. if _is_degenerate_line_grid(blocks, line_count):
  127. return _merge_wrapped_lines(page.get_text("text"))
  128. paragraphs: list[str] = []
  129. for items, _, _ in blocks:
  130. paragraphs.extend(_merge_line_items(items))
  131. return "\n".join(paragraphs)
  132. def _is_degenerate_line_grid(
  133. blocks: list[tuple[list[tuple[str, float, str]], float, float]],
  134. line_count: int,
  135. ) -> bool:
  136. """判断整页是否为"每行一块、等距同字号"的退化网格(扫描件文本层典型特征)。
  137. 需同时满足:每块仅一行、块数≥3(至少两个间距可比较均匀性)、
  138. 相邻块间距一致、各行字号一致。标题+正文等少量块或间距/字号有变化的
  139. 页面信任版面块结构,不视为退化。
  140. """
  141. if len(blocks) < 3 or len(blocks) < line_count:
  142. return False
  143. if any(len(items) != 1 for items, _, _ in blocks):
  144. return False
  145. gaps = [
  146. blocks[index + 1][1] - top for index, (_, top, _) in enumerate(blocks[:-1])
  147. ]
  148. if max(gaps) - min(gaps) > _DEGENERATE_GAP_TOLERANCE:
  149. return False
  150. sizes = [items[0][1] for items, _, _ in blocks]
  151. return max(sizes) - min(sizes) <= _DEGENERATE_SIZE_TOLERANCE
  152. def _pdf_text(path: Path) -> str:
  153. with fitz.open(stream=path.read_bytes(), filetype="pdf") as document:
  154. pages = [_pdf_page_text(page) for page in document]
  155. # 页与页之间以空行分隔(页边界即段落边界)
  156. return "\n\n".join(page for page in pages if page).strip()
  157. # 句末标点:一行以此结尾视为自然段落结束,不再与下一行合并
  158. _SENTENCE_END_RE = re.compile(r"[。!?!?;;::…..\"”’」』))\]}>》]+$")
  159. # 条目/章节起始标记:下一行以此开头视为新段落(如 "1." "(一)" "第2章" "一、")
  160. # 数字含全角0-9,分隔符含全角句点"."(公文"1."式三级标题)
  161. _LIST_START_RE = re.compile(
  162. r"^(?:"
  163. r"[0-90-9]{1,3}[.、)).]" # 1. / 1、 / 1) / 1.
  164. r"|[((][一二三四五六七八九十百0-90-9]{1,4}[))]" # (一) / (1)
  165. r"|[一二三四五六七八九十]{1,3}、" # 一、
  166. r"|第[一二三四五六七八九十百千0-90-9]{1,5}[章节条部分篇]" # 第3章 / 第五节
  167. r")"
  168. )
  169. def _join_lines(previous: str, following: str) -> str:
  170. """合并段内被换行符断开的两行:两侧均为 ASCII 字母数字时补空格,其余直接相连。"""
  171. if previous and following and previous[-1].isascii() and previous[-1].isalnum() \
  172. and following[0].isascii() and following[0].isalnum():
  173. return f"{previous} {following}"
  174. return previous + following
  175. def _merge_line_items(items: list[tuple[str, float, str]]) -> list[str]:
  176. """将一组行(文本、主字号、主字体签名)合并为段落列表。
  177. 新段落(切分)的情况:
  178. 1. 下一行以条目/章节编号开头;
  179. 2. 相邻行主字号相对差 ≥ _FONT_SIZE_SPLIT_RATIO,或主字体签名不同
  180. (标题与正文同块的兜底:公文字号体系下黑体/楷体标题与仿宋正文
  181. 同字号,仅字体家族或加粗不同;字号/字体为空表示无版面信息,
  182. 不参与比较。字体切分要求前一行至少 2 个字符——单字符行如项目
  183. 符号"•"不可能是标题,避免符号与条目文本被字体差异拆开);
  184. 3. 当前缓冲以句末标点结尾(在追加该行后立即收尾)。
  185. 其余情况视为段内软换行,按 _join_lines 规则合并。
  186. """
  187. paragraphs: list[str] = []
  188. buffer = ""
  189. previous_size = 0.0
  190. previous_font = ""
  191. previous_text = ""
  192. for text, size, font in items:
  193. if buffer and _LIST_START_RE.match(text):
  194. paragraphs.append(buffer)
  195. buffer = text
  196. elif buffer and (
  197. (
  198. previous_size
  199. and size
  200. and abs(size - previous_size) / max(size, previous_size)
  201. >= _FONT_SIZE_SPLIT_RATIO
  202. )
  203. or (
  204. previous_font
  205. and font
  206. and font != previous_font
  207. and len(previous_text) >= 2
  208. )
  209. ):
  210. paragraphs.append(buffer)
  211. buffer = text
  212. elif buffer:
  213. buffer = _join_lines(buffer, text)
  214. else:
  215. buffer = text
  216. previous_size = size
  217. previous_font = font
  218. previous_text = text
  219. if buffer and _SENTENCE_END_RE.search(buffer):
  220. paragraphs.append(buffer)
  221. buffer = ""
  222. if buffer:
  223. paragraphs.append(buffer)
  224. return paragraphs
  225. def _merge_wrapped_lines(page_text: str) -> str:
  226. """纯文本兜底:PDF 复制文本时会在段内软换行处插入 \\n,此处按启发式规则合并。
  227. 换行保留(视为新段落)的情况:
  228. 1. 空行(原始段落边界);
  229. 2. 上一行以句末标点结尾;
  230. 3. 下一行以条目/章节编号开头。
  231. 其余情况视为段内软换行,直接合并(中文不加空格,英文单词间补一个空格)。
  232. """
  233. paragraphs: list[str] = []
  234. group: list[tuple[str, float, str]] = []
  235. for raw_line in page_text.splitlines():
  236. line = raw_line.strip()
  237. if not line:
  238. paragraphs.extend(_merge_line_items(group))
  239. group = []
  240. else:
  241. group.append((line, 0.0, ""))
  242. paragraphs.extend(_merge_line_items(group))
  243. return "\n".join(paragraphs)
  244. def extract_document_content(path: Path, extension: str) -> ContentExtraction:
  245. normalized = extension.lower().lstrip(".")
  246. if normalized not in {"pdf", "docx"}:
  247. return ContentExtraction(
  248. text=None,
  249. status=ContentExtractStatus.UNSUPPORTED.value,
  250. extracted_at=None,
  251. )
  252. extracted_at = datetime.now(timezone.utc).replace(tzinfo=None)
  253. try:
  254. text = _pdf_text(path) if normalized == "pdf" else _word_text(path)
  255. except Exception as exc:
  256. logger.exception("文档正文提取失败:extension=%s", normalized)
  257. return ContentExtraction(
  258. text=None,
  259. status=ContentExtractStatus.FAILED.value,
  260. extracted_at=extracted_at,
  261. error=sanitize_extract_error(exc),
  262. )
  263. return ContentExtraction(
  264. text=text or None,
  265. status=(
  266. ContentExtractStatus.SUCCESS.value
  267. if text
  268. else ContentExtractStatus.EMPTY.value
  269. ),
  270. extracted_at=extracted_at,
  271. error=None,
  272. )
  273. __all__ = [
  274. "ContentExtraction",
  275. "build_search_text",
  276. "extract_document_content",
  277. "sanitize_extract_error",
  278. ]