DocumentContentService.java 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462
  1. package com.dms.service;
  2. import java.io.IOException;
  3. import java.nio.file.Files;
  4. import java.nio.file.Path;
  5. import java.time.LocalDateTime;
  6. import java.time.ZoneOffset;
  7. import java.util.ArrayList;
  8. import java.util.List;
  9. import java.util.Locale;
  10. import java.util.regex.Matcher;
  11. import java.util.regex.Pattern;
  12. import org.apache.pdfbox.Loader;
  13. import org.apache.pdfbox.pdmodel.PDDocument;
  14. import org.apache.pdfbox.text.PDFTextStripper;
  15. import org.apache.pdfbox.text.TextPosition;
  16. import org.apache.poi.xwpf.usermodel.XWPFDocument;
  17. import org.apache.poi.xwpf.usermodel.XWPFParagraph;
  18. import org.apache.poi.xwpf.usermodel.XWPFTable;
  19. import org.apache.poi.xwpf.usermodel.XWPFTableCell;
  20. import org.apache.poi.xwpf.usermodel.XWPFTableRow;
  21. import org.slf4j.Logger;
  22. import org.slf4j.LoggerFactory;
  23. import org.springframework.stereotype.Service;
  24. /**
  25. * 不依赖AI或Office进程的PDF/DOCX正文提取与检索文本构建,
  26. * 对齐 backend/dms/services/document_content_service.py
  27. * (Python fitz/PyMuPDF → PDFBox,python-docx → POI)。
  28. *
  29. * <p>阶段10同步:① PDF正文按"版面块"识别段落(块边界即段落边界,块内合并
  30. * 段内软换行,相邻行主字号突变亦视为段落边界);② 整页为逐行等距同字号的
  31. * 退化网格(扫描件文本层典型特征)时回退纯文本启发式合并;③ 提取结果携带
  32. * 脱敏失败原因(content_extract_error),供管理员"编辑"界面排障与重新解析。</p>
  33. */
  34. @Service
  35. public class DocumentContentService {
  36. private static final Logger logger = LoggerFactory.getLogger(DocumentContentService.class);
  37. /** 提取结果(对齐 ContentExtraction dataclass,error 缺省 null)。 */
  38. public record ContentExtraction(String text, String status, LocalDateTime extractedAt,
  39. String error) {
  40. public ContentExtraction(String text, String status, LocalDateTime extractedAt) {
  41. this(text, status, extractedAt, null);
  42. }
  43. }
  44. // ------------------------------------------------------------ 提取失败原因脱敏
  45. private static final int ERROR_MAX_LENGTH = 480;
  46. /** Windows 盘符绝对路径(C:\... 或 C:/...)。 */
  47. private static final Pattern WINDOWS_PATH = Pattern.compile("[A-Za-z]:[\\/][^\\s,;]*");
  48. /** POSIX 根路径(前一个字符非单词字符,避免误伤 URL/比例线)。 */
  49. private static final Pattern POSIX_PATH = Pattern.compile("(?<![\\w])/[A-Za-z0-9_./-]+");
  50. /** 对齐 sanitize_extract_error:异常类型+首行信息,绝对路径替换为 &lt;path&gt;。 */
  51. public static String sanitizeExtractError(Throwable ex) {
  52. String raw = String.valueOf(ex.getMessage());
  53. String message = "";
  54. if (!raw.isBlank()) {
  55. int lineBreak = raw.indexOf('\n');
  56. message = lineBreak >= 0 ? raw.substring(0, lineBreak) : raw;
  57. }
  58. message = WINDOWS_PATH.matcher(message).replaceAll("<path>");
  59. message = POSIX_PATH.matcher(message).replaceAll("<path>");
  60. String text = message.isEmpty()
  61. ? ex.getClass().getSimpleName()
  62. : ex.getClass().getSimpleName() + ": " + message;
  63. return text.length() > ERROR_MAX_LENGTH ? text.substring(0, ERROR_MAX_LENGTH) : text;
  64. }
  65. /** 对齐 build_search_text:非空部分以空格连接后 strip。 */
  66. public static String buildSearchText(String name, String summary, List<String> tags,
  67. String contentText) {
  68. List<String> parts = new ArrayList<>();
  69. if (name != null && !name.isEmpty()) {
  70. parts.add(name);
  71. }
  72. if (summary != null && !summary.isEmpty()) {
  73. parts.add(summary);
  74. }
  75. if (tags != null) {
  76. for (String tag : tags) {
  77. if (tag != null && !tag.isEmpty()) {
  78. parts.add(tag);
  79. }
  80. }
  81. }
  82. if (contentText != null && !contentText.isEmpty()) {
  83. parts.add(contentText);
  84. }
  85. return String.join(" ", parts).strip();
  86. }
  87. // ------------------------------------------------------------ DOCX 提取
  88. /** 对齐 _word_text:段落 → 表格递归单元格 → 各节页眉页脚。 */
  89. private static String wordText(Path path) throws IOException {
  90. try (XWPFDocument document = new XWPFDocument(Files.newInputStream(path))) {
  91. List<String> values = new ArrayList<>();
  92. addParagraphs(document.getParagraphs(), values);
  93. addTables(document.getTables(), values);
  94. for (var header : document.getHeaderList()) {
  95. addParagraphs(header.getParagraphs(), values);
  96. addTables(header.getTables(), values);
  97. }
  98. for (var footer : document.getFooterList()) {
  99. addParagraphs(footer.getParagraphs(), values);
  100. addTables(footer.getTables(), values);
  101. }
  102. return String.join("\n", values).strip();
  103. }
  104. }
  105. private static void addParagraphs(List<XWPFParagraph> paragraphs, List<String> values) {
  106. for (XWPFParagraph paragraph : paragraphs) {
  107. String text = paragraph.getText();
  108. String stripped = text == null ? "" : text.strip();
  109. if (!stripped.isEmpty()) {
  110. values.add(stripped);
  111. }
  112. }
  113. }
  114. private static void addTables(List<XWPFTable> tables, List<String> values) {
  115. for (XWPFTable table : tables) {
  116. for (XWPFTableRow row : table.getRows()) {
  117. for (XWPFTableCell cell : row.getTableCells()) {
  118. addParagraphs(cell.getParagraphs(), values);
  119. addTables(cell.getTables(), values);
  120. }
  121. }
  122. }
  123. }
  124. // ------------------------------------------------------------ PDF 提取(版面块段落化)
  125. /**
  126. * 块内相邻行主字号相对差达到该比例时视为段落边界(标题与正文同块的兜底)。
  127. * 注:PDFBox 的字形高度(getHeightDir)较 fitz 的字号度量压缩了相对差异
  128. * (实测16pt/11pt标题正文 ≈ 0.195),故取 0.15 以对齐 Python 版 0.2 的效果。
  129. */
  130. private static final double FONT_SIZE_SPLIT_RATIO = 0.15;
  131. /** 退化版面判定:逐行等距网格的间距容差(pt)与字号容差(pt)。 */
  132. private static final double DEGENERATE_GAP_TOLERANCE = 2.0;
  133. private static final double DEGENERATE_SIZE_TOLERANCE = 0.5;
  134. /**
  135. * 块切分阈值:行距(top-to-top)超出中位行距该倍数视为版面块边界——
  136. * 段间距显著大于行间距,标题/落款因前后留白自然独立成块;
  137. * 以自适应中位数为基准可兼容不同字体度量(PDFBox 高度 ≠ 字号)。
  138. */
  139. private static final double BLOCK_PITCH_RATIO = 1.4;
  140. /** 阈值下限附加量(pt):抑制极小行距页面的比例失真。 */
  141. private static final double BLOCK_PITCH_FLOOR = 2.0;
  142. /** 句末标点:一行以此结尾视为自然段落结束,不再与下一行合并。 */
  143. private static final Pattern SENTENCE_END = Pattern.compile(
  144. "[。!?!?;;::…..\u201d\u2019」』))\\]}>》]+$");
  145. /** 条目/章节起始标记:下一行以此开头视为新段落(如 "1." "(一)" "第2章")。 */
  146. private static final Pattern LIST_START = Pattern.compile(
  147. "^(?:\\d{1,3}[.、))]"
  148. + "|[((][一二三四五六七八九十百\\d]{1,4}[))]"
  149. + "|[一二三四五六七八九十]{1,3}、"
  150. + "|第[一二三四五六七八九十百千\\d]{1,5}[章节条部分篇])");
  151. /** 版面行:(文本, 主字号, bbox顶, bbox底)。 */
  152. private record LineItem(String text, double mainSize, double top, double bottom) {
  153. }
  154. /** 版面块:(行列表, bbox顶, bbox底)。 */
  155. private record TextBlock(List<LineItem> items, double top, double bottom) {
  156. }
  157. /**
  158. * 逐字符收集版面行(对齐 fitz page.get_text("dict") 的 lines 结构):
  159. * PDFTextStripper 按 sortByPosition 顺序回调 writeString,
  160. * writeLineSeparator 处收行;行主字号取行内非空白字符的最大高度。
  161. */
  162. private static final class LineCollector extends PDFTextStripper {
  163. private final List<LineItem> lines = new ArrayList<>();
  164. private final StringBuilder current = new StringBuilder();
  165. private final List<Double> sizes = new ArrayList<>();
  166. private double top = Double.MAX_VALUE;
  167. private double bottom = -Double.MAX_VALUE;
  168. LineCollector() throws IOException {
  169. setSortByPosition(true);
  170. }
  171. @Override
  172. protected void writeString(String text, List<TextPosition> positions) {
  173. for (TextPosition position : positions) {
  174. String unicode = position.getUnicode();
  175. if (unicode == null) {
  176. continue;
  177. }
  178. current.append(unicode);
  179. if (!unicode.isBlank()) {
  180. sizes.add((double) position.getHeightDir());
  181. }
  182. double y = position.getYDirAdj();
  183. double height = position.getHeightDir();
  184. top = Math.min(top, y);
  185. bottom = Math.max(bottom, y + height);
  186. }
  187. }
  188. @Override
  189. protected void writeLineSeparator() {
  190. closeLine();
  191. }
  192. @Override
  193. public String getText(PDDocument document) throws IOException {
  194. String result = super.getText(document);
  195. closeLine();
  196. return result;
  197. }
  198. private void closeLine() {
  199. String text = current.toString().strip();
  200. if (!text.isEmpty()) {
  201. double mainSize = 0.0;
  202. for (double size : sizes) {
  203. mainSize = Math.max(mainSize, size);
  204. }
  205. lines.add(new LineItem(text, mainSize, top, bottom));
  206. }
  207. current.setLength(0);
  208. sizes.clear();
  209. top = Double.MAX_VALUE;
  210. bottom = -Double.MAX_VALUE;
  211. }
  212. List<LineItem> drainLines() {
  213. List<LineItem> drained = new ArrayList<>(lines);
  214. lines.clear();
  215. return drained;
  216. }
  217. }
  218. /** 对齐 _pdf_page_text:按版面块识别段落;退化网格回退纯文本启发式。 */
  219. private static String pdfPageText(PDDocument document, int pageNumber)
  220. throws IOException {
  221. LineCollector collector = new LineCollector();
  222. collector.setStartPage(pageNumber);
  223. collector.setEndPage(pageNumber);
  224. collector.getText(document);
  225. List<LineItem> lines = collector.drainLines();
  226. if (lines.isEmpty()) {
  227. return "";
  228. }
  229. List<TextBlock> blocks = groupBlocks(lines);
  230. if (isDegenerateLineGrid(blocks, lines.size())) {
  231. PDFTextStripper plain = new PDFTextStripper();
  232. plain.setStartPage(pageNumber);
  233. plain.setEndPage(pageNumber);
  234. return mergeWrappedLines(plain.getText(document));
  235. }
  236. List<String> paragraphs = new ArrayList<>();
  237. for (TextBlock block : blocks) {
  238. paragraphs.addAll(mergeLineItems(block.items()));
  239. }
  240. return String.join("\n", paragraphs);
  241. }
  242. /**
  243. * 版面块分组(近似 fitz 按邻近度聚块):行距超过"中位行距×
  244. * BLOCK_PITCH_RATIO(且至少多 BLOCK_PITCH_FLOOR pt)"时视为块边界。
  245. * 行数不足(无中位样本)时不切分,整页视为一个块,
  246. * 由 mergeLineItems 的字号/标点规则兜底分段。
  247. */
  248. private static List<TextBlock> groupBlocks(List<LineItem> lines) {
  249. List<Double> pitches = new ArrayList<>();
  250. for (int index = 0; index + 1 < lines.size(); index++) {
  251. pitches.add(lines.get(index + 1).top() - lines.get(index).top());
  252. }
  253. double threshold = Double.MAX_VALUE;
  254. if (pitches.size() >= 2) {
  255. List<Double> sorted = new ArrayList<>(pitches);
  256. java.util.Collections.sort(sorted);
  257. double median = sorted.get(sorted.size() / 2);
  258. threshold = Math.max(median * BLOCK_PITCH_RATIO,
  259. median + BLOCK_PITCH_FLOOR);
  260. }
  261. List<TextBlock> blocks = new ArrayList<>();
  262. List<LineItem> current = new ArrayList<>();
  263. for (int index = 0; index < lines.size(); index++) {
  264. LineItem line = lines.get(index);
  265. if (!current.isEmpty() && index > 0) {
  266. double pitch = line.top() - lines.get(index - 1).top();
  267. if (pitch > threshold) {
  268. blocks.add(closeBlock(current));
  269. current = new ArrayList<>();
  270. }
  271. }
  272. current.add(line);
  273. }
  274. if (!current.isEmpty()) {
  275. blocks.add(closeBlock(current));
  276. }
  277. return blocks;
  278. }
  279. private static TextBlock closeBlock(List<LineItem> items) {
  280. double top = Double.MAX_VALUE;
  281. double bottom = -Double.MAX_VALUE;
  282. for (LineItem item : items) {
  283. top = Math.min(top, item.top());
  284. bottom = Math.max(bottom, item.bottom());
  285. }
  286. return new TextBlock(List.copyOf(items), top, bottom);
  287. }
  288. /**
  289. * 对齐 _is_degenerate_line_grid:整页"每行一块、等距同字号"的退化网格
  290. * (扫描件文本层逐行定位的典型特征)时块结构无分组价值。
  291. */
  292. private static boolean isDegenerateLineGrid(List<TextBlock> blocks, int lineCount) {
  293. if (blocks.size() < 3 || blocks.size() < lineCount) {
  294. return false;
  295. }
  296. for (TextBlock block : blocks) {
  297. if (block.items().size() != 1) {
  298. return false;
  299. }
  300. }
  301. double minGap = Double.MAX_VALUE;
  302. double maxGap = -Double.MAX_VALUE;
  303. for (int index = 0; index + 1 < blocks.size(); index++) {
  304. double gap = blocks.get(index + 1).top() - blocks.get(index).top();
  305. minGap = Math.min(minGap, gap);
  306. maxGap = Math.max(maxGap, gap);
  307. }
  308. if (maxGap - minGap > DEGENERATE_GAP_TOLERANCE) {
  309. return false;
  310. }
  311. double minSize = Double.MAX_VALUE;
  312. double maxSize = -Double.MAX_VALUE;
  313. for (TextBlock block : blocks) {
  314. double size = block.items().get(0).mainSize();
  315. minSize = Math.min(minSize, size);
  316. maxSize = Math.max(maxSize, size);
  317. }
  318. return maxSize - minSize <= DEGENERATE_SIZE_TOLERANCE;
  319. }
  320. /** 对齐 _merge_line_items:条目编号/字号突变/句末标点切分,其余按软换行合并。 */
  321. private static List<String> mergeLineItems(List<LineItem> items) {
  322. List<String> paragraphs = new ArrayList<>();
  323. StringBuilder buffer = new StringBuilder();
  324. double previousSize = 0.0;
  325. for (LineItem item : items) {
  326. String text = item.text();
  327. double size = item.mainSize();
  328. boolean split = false;
  329. if (!buffer.isEmpty() && LIST_START.matcher(text).find()) {
  330. split = true;
  331. } else if (!buffer.isEmpty() && previousSize > 0 && size > 0
  332. && Math.abs(size - previousSize) / Math.max(size, previousSize)
  333. >= FONT_SIZE_SPLIT_RATIO) {
  334. split = true;
  335. }
  336. if (buffer.isEmpty()) {
  337. buffer.append(text);
  338. } else if (split) {
  339. paragraphs.add(buffer.toString());
  340. buffer.setLength(0);
  341. buffer.append(text);
  342. } else {
  343. joinLines(buffer, text);
  344. }
  345. previousSize = size;
  346. Matcher endMatcher = SENTENCE_END.matcher(buffer);
  347. if (!buffer.isEmpty() && endMatcher.find()) {
  348. paragraphs.add(buffer.toString());
  349. buffer.setLength(0);
  350. }
  351. }
  352. if (!buffer.isEmpty()) {
  353. paragraphs.add(buffer.toString());
  354. }
  355. return paragraphs;
  356. }
  357. /** 对齐 _join_lines:两侧均为 ASCII 字母数字时补空格,其余直接相连。 */
  358. private static void joinLines(StringBuilder previous, String following) {
  359. if (previous.isEmpty() || following.isEmpty()) {
  360. previous.append(following);
  361. return;
  362. }
  363. char left = previous.charAt(previous.length() - 1);
  364. char right = following.charAt(0);
  365. if (left < 128 && Character.isLetterOrDigit(left)
  366. && right < 128 && Character.isLetterOrDigit(right)) {
  367. previous.append(' ').append(following);
  368. return;
  369. }
  370. previous.append(following);
  371. }
  372. /** 对齐 _merge_wrapped_lines:纯文本兜底,空行/句末标点/条目编号视为段落边界。 */
  373. private static String mergeWrappedLines(String pageText) {
  374. List<String> paragraphs = new ArrayList<>();
  375. List<LineItem> group = new ArrayList<>();
  376. for (String rawLine : pageText.split("\n")) {
  377. String line = rawLine.strip();
  378. if (line.isEmpty()) {
  379. paragraphs.addAll(mergeLineItems(group));
  380. group = new ArrayList<>();
  381. } else {
  382. group.add(new LineItem(line, 0.0, 0.0, 0.0));
  383. }
  384. }
  385. paragraphs.addAll(mergeLineItems(group));
  386. return String.join("\n", paragraphs);
  387. }
  388. /** 对齐 _pdf_text:逐页版面段落化,页与页之间以空行分隔(页边界即段落边界)。 */
  389. private static String pdfText(Path path) throws IOException {
  390. try (PDDocument document = Loader.loadPDF(Files.readAllBytes(path))) {
  391. List<String> pages = new ArrayList<>();
  392. for (int index = 1; index <= document.getNumberOfPages(); index++) {
  393. pages.add(pdfPageText(document, index));
  394. }
  395. List<String> nonEmpty = pages.stream().filter(page -> !page.isEmpty()).toList();
  396. return String.join("\n\n", nonEmpty).strip();
  397. }
  398. }
  399. // ------------------------------------------------------------ 提取入口
  400. /** 对齐 extract_document_content:仅 pdf/docx,异常 FAILED(含脱敏原因)。 */
  401. public ContentExtraction extractDocumentContent(Path path, String extension) {
  402. String normalized = extension == null
  403. ? ""
  404. : extension.toLowerCase(Locale.ROOT).replaceFirst("^\\.+", "");
  405. if (!"pdf".equals(normalized) && !"docx".equals(normalized)) {
  406. return new ContentExtraction(null, "UNSUPPORTED", null, null);
  407. }
  408. LocalDateTime extractedAt = LocalDateTime.now(ZoneOffset.UTC);
  409. String text;
  410. try {
  411. text = "pdf".equals(normalized) ? pdfText(path) : wordText(path);
  412. } catch (Exception ex) {
  413. logger.error("文档正文提取失败:extension={}", normalized, ex);
  414. return new ContentExtraction(null, "FAILED", extractedAt, sanitizeExtractError(ex));
  415. }
  416. boolean hasText = text != null && !text.isEmpty();
  417. return new ContentExtraction(
  418. hasText ? text : null,
  419. hasText ? "SUCCESS" : "EMPTY",
  420. extractedAt,
  421. null);
  422. }
  423. }