5 小时以前 9bad721754fe8bbe2e5f459d0706e0fefac569f3
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
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();
    }
 
}