package cn.iocoder.yudao.module.qcreport.service.aiimport.document; import cn.iocoder.yudao.module.qcreport.config.QcReportAiImportProperties; import jakarta.annotation.Resource; import lombok.extern.slf4j.Slf4j; import org.apache.poi.ss.usermodel.Cell; import org.apache.poi.ss.usermodel.DataFormatter; import org.apache.poi.ss.usermodel.Row; import org.apache.poi.ss.usermodel.Sheet; import org.apache.poi.xssf.usermodel.XSSFWorkbook; import org.apache.poi.xwpf.usermodel.IBodyElement; import org.apache.poi.xwpf.usermodel.XWPFDocument; import org.apache.poi.xwpf.usermodel.XWPFHeaderFooter; import org.apache.poi.xwpf.usermodel.XWPFParagraph; import org.apache.poi.xwpf.usermodel.XWPFTable; import org.apache.poi.xwpf.usermodel.XWPFTableCell; import org.apache.poi.xwpf.usermodel.XWPFTableRow; import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTTc; import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTTcPr; import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTVMerge; import org.openxmlformats.schemas.wordprocessingml.x2006.main.STMerge; import org.springframework.core.annotation.Order; import org.springframework.stereotype.Component; import java.io.ByteArrayInputStream; import java.io.IOException; import java.math.BigInteger; import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.LinkedHashSet; import java.util.List; import java.util.Set; import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; import static cn.iocoder.yudao.module.qcreport.enums.ErrorCodeConstants.AI_IMPORT_FILE_EMPTY; import static cn.iocoder.yudao.module.qcreport.enums.ErrorCodeConstants.AI_IMPORT_FILE_LEGACY_OFFICE; import static cn.iocoder.yudao.module.qcreport.enums.ErrorCodeConstants.AI_IMPORT_OFFICE_CORRUPT; /** * Word / Excel / 纯文本抽取器。 *

* 用 POI 而不是 Tika,两个原因,缺一不可: * 检验报告的核心信息在表格里,行列结构一旦被 Tika 拍平成一段文字就再也找不回来; * 而 {@code tika-parsers-standard-package} 会拖进几百 MB 的解析器依赖, * 一个只处理四种格式的模块没必要背这个包袱。 * *

页眉页脚

* .docx 的 page header / footer 存在正文之外,{@code getBodyElements()} 看不到它们。 * 只看正文的文件,「公司抬头在页眉、厂址电话在页脚」这类报告会把两头都丢掉—— * 模型连文字都没见过,自然也不会想到用 {@code ReportHeader} / {@code ReportFooter}。 * 所以页眉页脚一并读入,用 {@code [页眉]} / {@code [页脚]} 标记与正文区分。 * PDF 与图片不需要这套标记:那两种通道里页眉页脚本来就是页面内容的一部分。 * *

旧格式 .doc / .xls

* 在 {@link #supports} 里认领、在 {@link #extract} 里拒绝。 * 让它走到「格式不支持」那条通用错误去是不行的:那边只会告诉用户「仅支持 PDF / Word / Excel」, * 而用户手上的文件扩展名看起来完全合规(.doc 也是 Word),提示等于没说。 * 认领之后就能给出真正的出路——另存为 .docx。 */ @Slf4j @Component @Order(3) public class OfficeImportAdapter implements QcReportImportAdapter { private static final Set EXTENSIONS = Set.of("docx", "xlsx", "txt", "csv", "doc", "xls"); /** 只含空白字符的 UTF-8 BOM,解码前要先剥掉,否则第一行的第一个单元格会带一个不可见字符 */ private static final byte[] BOM_UTF_8 = {(byte) 0xEF, (byte) 0xBB, (byte) 0xBF}; /** 页眉/页脚标记:不标记的话,模型无从判断这段是每页重复的抬头还是正文里的普通文本 */ private static final String HEADER_MARK = "[页眉]"; private static final String FOOTER_MARK = "[页脚]"; /** 页眉/页脚只是软提示(进 warnings),要不要变成组件由模型按组件 hint 判断,用户再确认 */ private static final String HEADER_FOOTER_NOTE = "原件带有页眉/页脚,已用 [页眉] / [页脚] 标记并入识别内容"; @Resource private QcReportAiImportProperties properties; @Override public boolean supports(String extension, String contentType) { return EXTENSIONS.contains(extension); } @Override public QcReportDocumentExtract extract(byte[] content, String originalFilename) { String extension = extensionOf(originalFilename); if ("doc".equals(extension) || "xls".equals(extension)) { throw exception(AI_IMPORT_FILE_LEGACY_OFFICE, originalFilename, extension); } if (content == null || content.length == 0) { throw exception(AI_IMPORT_FILE_EMPTY, originalFilename, 1); } List notes = List.of(); String text; if ("docx".equals(extension)) { DocxRead read = readDocx(content, originalFilename); text = read.text(); notes = read.notes(); } else { text = switch (extension) { case "xlsx" -> readXlsx(content, originalFilename); case "txt", "csv" -> readPlainText(content); default -> ""; }; } if (text.isBlank()) { throw exception(AI_IMPORT_FILE_EMPTY, originalFilename, 1); } return QcReportDocumentExtract.ofText(text, 1, extractorNameOf(extension), notes); } /** * 读 .docx,正文与页眉/页脚一起读。 *

* 正文按 {@code getBodyElements()} 迭代而不是「先所有段落、再所有表格」:报告里表格夹在段落之间, * 分开收集会把版面顺序彻底打乱,模型看到的是一堆标题堆在开头、表格堆在结尾。 *

* 表格用 Tab 分隔单元格、换行分隔行,并在前后加标记——标记的作用是让模型知道 * 「这几行属于同一个表格」,没有它,一段用 Tab 拼起来的文本和普通段落无从区分。 * 表格内部的合并单元格由 {@link #appendTable} 归一化成对齐的网格,合并信息因此不会丢失。 *

* 页眉/页脚必须读:报告的抬头(公司名、报告名)和落款(厂址、电话)通常就住在这里, * 而 {@code getBodyElements()} 只看正文,不读的话模型压根没见过这些文字, * 也就不可能把它们认成 {@code ReportHeader} / {@code ReportFooter}。读到的内容用 * {@code [页眉]} / {@code [页脚]} 包住,顺序与文档视觉顺序一致(页眉在前、页脚在后)。 */ private DocxRead readDocx(byte[] content, String originalFilename) { try (XWPFDocument document = new XWPFDocument(new ByteArrayInputStream(content))) { StringBuilder sb = new StringBuilder(); String header = collectHeaderFooter(document.getHeaderList()); String footer = collectHeaderFooter(document.getFooterList()); if (!header.isBlank()) { sb.append(HEADER_MARK).append('\n').append(header); } appendBodyElements(sb, document.getBodyElements()); if (!footer.isBlank()) { sb.append(FOOTER_MARK).append('\n').append(footer); } List notes = header.isBlank() && footer.isBlank() ? List.of() : List.of(HEADER_FOOTER_NOTE); return new DocxRead(sb.toString(), notes); } catch (IOException | RuntimeException e) { log.warn("docx 解析失败,file={}", originalFilename, e); throw exception(AI_IMPORT_OFFICE_CORRUPT, originalFilename, "docx"); } } /** * 收一个文档的页眉或页脚。 *

* 两件事必须做,少一件都会把噪声当信号: *

*/ private static String collectHeaderFooter(List parts) { Set distinct = new LinkedHashSet<>(); for (XWPFHeaderFooter part : parts) { StringBuilder sb = new StringBuilder(); appendBodyElements(sb, part.getBodyElements()); String text = sb.toString().trim(); if (!text.isEmpty()) { distinct.add(text); } } return String.join("\n", distinct); } /** * 按顺序把段落与表格写进缓冲区。正文、页眉、页脚共用一份,避免两套表格逻辑各自漂移。 */ private static void appendBodyElements(StringBuilder sb, List elements) { for (IBodyElement element : elements) { if (element instanceof XWPFParagraph paragraph) { appendLine(sb, paragraph.getText()); } else if (element instanceof XWPFTable table) { appendTable(sb, table); } } } /** * 把一张表归一化成稳定网格后再输出。 *

* 直接按 {@code row.getTableCells()} 拼 Tab 是不够的:合并单元格会让每行的列数各不相同, * 模型收到的是一张行列错位、参差不齐的「表」。 *

    *
  • {@code w:gridSpan} 横向合并的格子,POI 只返回一个 {@code XWPFTableCell}, * 被它盖住的后几列凭空消失 ⇒ 同一行的列与表头对不上;
  • *
  • {@code w:vMerge} 纵向合并的续格,POI 取到的文字是空串 ⇒ 分组名只在第一行出现, * 后面几行看起来是「没有分组的独立检验项」,模型自然想不到这是两层结构。
  • *
* 后果不只是排版难看:模型看不到两层结构,就不会去挑「分组检验项表」组件, * 只能退而求其次挑平铺的检验表,把分组信息整个丢掉。 *

* 所以这里把合并信息翻译成模型看得见的字符: *

    *
  • {@code gridSpan=N} 的文字落在该组第 1 格,其后补 N-1 个空占位,列位与表头对齐;
  • *
  • {@code vMerge} 续格输出 {@link QcReportDocumentExtract#VERTICAL_MERGE_MARK},明说「同上一行」;
  • *
  • 每行尾部补齐到整表网格宽度,短行不再被当成「列数就这么多」。
  • *
* 续格自身带文字时以文字为准:续格在 Word 里本不该有内容,真有就说明制表不规范, * 此时丢文字比丢结构更可惜。整表网格宽度取所有行的最大值,而不是第一行的列数—— * 表头常常带横向合并,按表头宽度切会把下面几行截断。 */ private static void appendTable(StringBuilder sb, XWPFTable table) { List> gridRows = new ArrayList<>(); int width = 0; for (XWPFTableRow row : table.getRows()) { List grid = new ArrayList<>(); for (XWPFTableCell cell : row.getTableCells()) { grid.add(cellText(cell)); for (int i = 1; i < gridSpanOf(cell); i++) { grid.add(""); } } width = Math.max(width, grid.size()); gridRows.add(grid); } sb.append("[表格开始]\n"); for (List grid : gridRows) { while (grid.size() < width) { grid.add(""); } appendLine(sb, String.join("\t", grid)); } sb.append("[表格结束]\n"); } /** 单元格文字;空白且是纵向合并续格时给出 {@code ↑同上} 而不是空串 */ private static String cellText(XWPFTableCell cell) { String text = cell.getText().replaceAll("[\\r\\n]+", " ").trim(); if (text.isEmpty() && isVerticalMergeContinuation(cell)) { return QcReportDocumentExtract.VERTICAL_MERGE_MARK; } return text; } /** * 是否是纵向合并的「续格」。 *

* {@code } 不带 val 与 {@code w:val="continue"} 都表示续格(前者是 Word 的常见写法); * {@code w:val="restart"} 是合并的起始格,它带着真正的文字,不算续格。 */ private static boolean isVerticalMergeContinuation(XWPFTableCell cell) { CTTcPr tcPr = cellPropertiesOf(cell); if (tcPr == null || !tcPr.isSetVMerge()) { return false; } CTVMerge vMerge = tcPr.getVMerge(); return !vMerge.isSetVal() || vMerge.getVal() == STMerge.CONTINUE; } /** 横向合并的列数,非合并格为 1;值异常时一律按 1 处理,宁可少补占位也不要凭空多出列 */ private static int gridSpanOf(XWPFTableCell cell) { CTTcPr tcPr = cellPropertiesOf(cell); if (tcPr == null || !tcPr.isSetGridSpan()) { return 1; } BigInteger span = tcPr.getGridSpan().getVal(); return span == null || span.intValue() < 1 ? 1 : span.intValue(); } /** 取格属性;POI 对没有 {@code } 的单元格返回 null,不能直接链式调用 */ private static CTTcPr cellPropertiesOf(XWPFTableCell cell) { CTTc ctTc = cell.getCTTc(); return ctTc == null ? null : ctTc.getTcPr(); } /** docx 抽取结果:正文文本 + 要进 warnings 的软提示 */ private record DocxRead(String text, List notes) { } /** * 读 .xlsx。 *

* 用 {@link DataFormatter} 取显示值而不是原始值:原始值里 {@code 1} 会变成 {@code 1.0}、 * 日期会变成一串序列号,模型看到这些基本只能瞎猜。用户眼里看到的是什么,就该给模型什么。 *

* 行数按 {@code maxXlsxRows} 封顶,空白行整行丢弃——超大表里大量空白行会把提示词灌满噪声。 */ private String readXlsx(byte[] content, String originalFilename) { try (XSSFWorkbook workbook = new XSSFWorkbook(new ByteArrayInputStream(content))) { StringBuilder sb = new StringBuilder(); DataFormatter formatter = new DataFormatter(); for (int sheetIndex = 0; sheetIndex < workbook.getNumberOfSheets(); sheetIndex++) { Sheet sheet = workbook.getSheetAt(sheetIndex); sb.append("[工作表: ").append(sheet.getSheetName()).append("]\n"); int lastRow = sheet.getLastRowNum(); int written = 0; for (int rowIndex = 0; rowIndex <= lastRow && written < properties.getMaxXlsxRows(); rowIndex++) { Row row = sheet.getRow(rowIndex); if (row == null) { continue; } List cells = new ArrayList<>(); boolean hasValue = false; for (int cellIndex = 0; cellIndex < row.getLastCellNum(); cellIndex++) { Cell cell = row.getCell(cellIndex); String value = cell == null ? "" : formatter.formatCellValue(cell).trim(); if (!value.isEmpty()) { hasValue = true; } cells.add(value); } if (!hasValue) { continue; } appendLine(sb, String.join("\t", cells)); written++; } if (written >= properties.getMaxXlsxRows() && lastRow + 1 > written) { sb.append("[本工作表超过 ").append(properties.getMaxXlsxRows()) .append(" 行,后续内容未读取]\n"); } } return sb.toString(); } catch (IOException | RuntimeException e) { log.warn("xlsx 解析失败,file={}", originalFilename, e); throw exception(AI_IMPORT_OFFICE_CORRUPT, originalFilename, "xlsx"); } } private String readPlainText(byte[] content) { if (startsWith(content, BOM_UTF_8)) { return new String(content, BOM_UTF_8.length, content.length - BOM_UTF_8.length, StandardCharsets.UTF_8); } if (content.length >= 2 && (content[0] & 0xFF) == 0xFF && (content[1] & 0xFF) == 0xFE) { return new String(content, 2, content.length - 2, StandardCharsets.UTF_16LE); } if (content.length >= 2 && (content[0] & 0xFF) == 0xFE && (content[1] & 0xFF) == 0xFF) { return new String(content, 2, content.length - 2, StandardCharsets.UTF_16BE); } // 没有 BOM 时一律按 UTF-8 读。GBK 在这里会被读成乱码,但那是「读出一堆问号」而不是失败, // 用户能从预览里看出来,比强行猜编码猜错更可控 return new String(content, StandardCharsets.UTF_8); } private static void appendLine(StringBuilder sb, String line) { if (line != null && !line.isBlank()) { sb.append(line).append('\n'); } } private static String extractorNameOf(String extension) { return "txt".equals(extension) || "csv".equals(extension) ? "plain-text" : "poi-" + extension; } private static boolean startsWith(byte[] content, byte[] prefix) { if (content.length < prefix.length) { return false; } for (int i = 0; i < prefix.length; i++) { if (content[i] != prefix[i]) { return false; } } return true; } private static String extensionOf(String filename) { int dot = filename == null ? -1 : filename.lastIndexOf('.'); return dot < 0 ? "" : filename.substring(dot + 1).toLowerCase(); } }