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 / 纯文本抽取器。
|
* <p>
|
* <b>用 POI 而不是 Tika</b>,两个原因,缺一不可:
|
* 检验报告的核心信息在表格里,行列结构一旦被 Tika 拍平成一段文字就再也找不回来;
|
* 而 {@code tika-parsers-standard-package} 会拖进几百 MB 的解析器依赖,
|
* 一个只处理四种格式的模块没必要背这个包袱。
|
*
|
* <h3>页眉页脚</h3>
|
* .docx 的 page header / footer 存在正文之外,{@code getBodyElements()} 看不到它们。
|
* 只看正文的文件,「公司抬头在页眉、厂址电话在页脚」这类报告会把两头都丢掉——
|
* 模型连文字都没见过,自然也不会想到用 {@code ReportHeader} / {@code ReportFooter}。
|
* 所以页眉页脚一并读入,用 {@code [页眉]} / {@code [页脚]} 标记与正文区分。
|
* PDF 与图片不需要这套标记:那两种通道里页眉页脚本来就是页面内容的一部分。
|
*
|
* <h3>旧格式 .doc / .xls</h3>
|
* 在 {@link #supports} 里<b>认领</b>、在 {@link #extract} 里<b>拒绝</b>。
|
* 让它走到「格式不支持」那条通用错误去是不行的:那边只会告诉用户「仅支持 PDF / Word / Excel」,
|
* 而用户手上的文件扩展名看起来完全合规(.doc 也是 Word),提示等于没说。
|
* 认领之后就能给出真正的出路——另存为 .docx。
|
*/
|
@Slf4j
|
@Component
|
@Order(3)
|
public class OfficeImportAdapter implements QcReportImportAdapter {
|
|
private static final Set<String> 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<String> 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,正文与页眉/页脚一起读。
|
* <p>
|
* 正文按 {@code getBodyElements()} 迭代而不是「先所有段落、再所有表格」:报告里表格夹在段落之间,
|
* 分开收集会把版面顺序彻底打乱,模型看到的是一堆标题堆在开头、表格堆在结尾。
|
* <p>
|
* 表格用 Tab 分隔单元格、换行分隔行,并在前后加标记——标记的作用是让模型知道
|
* 「这几行属于同一个表格」,没有它,一段用 Tab 拼起来的文本和普通段落无从区分。
|
* 表格内部的合并单元格由 {@link #appendTable} 归一化成对齐的网格,合并信息因此不会丢失。
|
* <p>
|
* <b>页眉/页脚必须读</b>:报告的抬头(公司名、报告名)和落款(厂址、电话)通常就住在这里,
|
* 而 {@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<String> 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");
|
}
|
}
|
|
/**
|
* 收一个文档的页眉或页脚。
|
* <p>
|
* 两件事必须做,少一件都会把噪声当信号:
|
* <ul>
|
* <li><b>空白过滤</b>:文档根本没有页眉时,POI 的默认策略仍会返回一个空 header,
|
* 不过滤就会凭空多出一个 {@code [页眉]} 标记,让模型以为原件有抬头。</li>
|
* <li><b>去重</b>:Word 允许首页/奇数页/偶数页各配一套,实际文件里这几套常常逐字相同,
|
* 不去重会把同一段抬头重复几遍喂给模型,既浪费上下文又像是在强调它。</li>
|
* </ul>
|
*/
|
private static String collectHeaderFooter(List<? extends XWPFHeaderFooter> parts) {
|
Set<String> 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<IBodyElement> elements) {
|
for (IBodyElement element : elements) {
|
if (element instanceof XWPFParagraph paragraph) {
|
appendLine(sb, paragraph.getText());
|
} else if (element instanceof XWPFTable table) {
|
appendTable(sb, table);
|
}
|
}
|
}
|
|
/**
|
* 把一张表归一化成稳定网格后再输出。
|
* <p>
|
* 直接按 {@code row.getTableCells()} 拼 Tab 是不够的:合并单元格会让每行的列数各不相同,
|
* 模型收到的是一张行列错位、参差不齐的「表」。
|
* <ul>
|
* <li>{@code w:gridSpan} 横向合并的格子,POI 只返回一个 {@code XWPFTableCell},
|
* 被它盖住的后几列凭空消失 ⇒ 同一行的列与表头对不上;</li>
|
* <li>{@code w:vMerge} 纵向合并的续格,POI 取到的文字是空串 ⇒ 分组名只在第一行出现,
|
* 后面几行看起来是「没有分组的独立检验项」,模型自然想不到这是两层结构。</li>
|
* </ul>
|
* 后果不只是排版难看:模型看不到两层结构,就不会去挑「分组检验项表」组件,
|
* 只能退而求其次挑平铺的检验表,把分组信息整个丢掉。
|
* <p>
|
* 所以这里把合并信息翻译成模型看得见的字符:
|
* <ul>
|
* <li>{@code gridSpan=N} 的文字落在该组第 1 格,其后补 N-1 个空占位,列位与表头对齐;</li>
|
* <li>{@code vMerge} 续格输出 {@link QcReportDocumentExtract#VERTICAL_MERGE_MARK},明说「同上一行」;</li>
|
* <li>每行尾部补齐到整表网格宽度,短行不再被当成「列数就这么多」。</li>
|
* </ul>
|
* 续格自身带文字时以文字为准:续格在 Word 里本不该有内容,真有就说明制表不规范,
|
* 此时丢文字比丢结构更可惜。整表网格宽度取所有行的最大值,而不是第一行的列数——
|
* 表头常常带横向合并,按表头宽度切会把下面几行截断。
|
*/
|
private static void appendTable(StringBuilder sb, XWPFTable table) {
|
List<List<String>> gridRows = new ArrayList<>();
|
int width = 0;
|
for (XWPFTableRow row : table.getRows()) {
|
List<String> 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<String> 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;
|
}
|
|
/**
|
* 是否是纵向合并的「续格」。
|
* <p>
|
* {@code <w:vMerge/>} 不带 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 <w:tcPr>} 的单元格返回 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<String> notes) {
|
}
|
|
/**
|
* 读 .xlsx。
|
* <p>
|
* 用 {@link DataFormatter} 取<b>显示值</b>而不是原始值:原始值里 {@code 1} 会变成 {@code 1.0}、
|
* 日期会变成一串序列号,模型看到这些基本只能瞎猜。用户眼里看到的是什么,就该给模型什么。
|
* <p>
|
* 行数按 {@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<String> 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();
|
}
|
|
}
|