11 小时以前 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
package cn.iocoder.yudao.module.qcreport.service.aiimport.document;
 
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDPage;
import org.apache.pdfbox.pdmodel.PDPageContentStream;
import org.apache.pdfbox.pdmodel.common.PDRectangle;
import org.apache.pdfbox.pdmodel.font.PDType1Font;
import org.apache.pdfbox.pdmodel.font.Standard14Fonts;
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.model.XWPFHeaderFooterPolicy;
import org.apache.poi.xwpf.usermodel.XWPFDocument;
import org.apache.poi.xwpf.usermodel.XWPFTable;
import org.apache.poi.xwpf.usermodel.XWPFTableCell;
import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTTcPr;
import org.openxmlformats.schemas.wordprocessingml.x2006.main.STMerge;
 
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.UncheckedIOException;
import java.math.BigInteger;
 
/**
 * 测试用的二进制样本,<b>在内存里现场生成</b>,不落地成资源文件。
 * <p>
 * 刻意不往 {@code src/test/resources} 放 .pdf / .docx 二进制:那种文件在仓库里既 review 不了也改不动,
 * 想知道它里面写了什么还得专门打开看。现场生成则让「样本里有什么内容」直接写在测试代码里,
 * 一眼就能看出断言为什么成立。
 * <p>
 * 代价是样本用了与生产同源的库(PDFBox / POI)来写——这确实是自证。
 * 但这里要验证的本来就不是「PDFBox 能不能解析 PDF」,而是<b>适配器拿到一份这样的文件会走哪条通道、
 * 抽出什么结构</b>,那正是这些样本能回答的问题。
 */
final class AiImportFixtures {
 
    private AiImportFixtures() {
    }
 
    /**
     * 有文本层的电子版 PDF,每页写一段足够长的 ASCII 文本(超过默认阈值 30 字符)。
     * <p>
     * 用 ASCII 而不是中文:PDF 的 14 号标准字体不含中文字形,写中文要么报错要么变成一串问号,
     * 而本用例只关心「有没有文本层」,不关心中文渲染。
     */
    static byte[] textPdf(int pageCount) {
        try (PDDocument document = new PDDocument()) {
            for (int i = 1; i <= pageCount; i++) {
                PDPage page = new PDPage(PDRectangle.A4);
                document.addPage(page);
                try (PDPageContentStream stream = new PDPageContentStream(document, page)) {
                    stream.beginText();
                    stream.setFont(new PDType1Font(Standard14Fonts.FontName.HELVETICA), 12);
                    stream.newLineAtOffset(50, 700);
                    stream.showText("Incoming Inspection Report page " + i);
                    stream.newLineAtOffset(0, -20);
                    stream.showText("Item Size Tolerance Result Qty Remark Inspector");
                    stream.endText();
                }
            }
            return save(document);
        } catch (IOException e) {
            throw new UncheckedIOException(e);
        }
    }
 
    /**
     * 只有页面、没有文字的 PDF——等价于一份扫描件:抽不出文本层,只能渲染成图。
     */
    static byte[] imageOnlyPdf(int pageCount) {
        try (PDDocument document = new PDDocument()) {
            for (int i = 0; i < pageCount; i++) {
                document.addPage(new PDPage(PDRectangle.A4));
            }
            return save(document);
        } catch (IOException e) {
            throw new UncheckedIOException(e);
        }
    }
 
    /**
     * 段落与表格<b>交替</b>排列的 .docx,用于验证抽取保留版面顺序而不是「所有段落 → 所有表格」。
     */
    static byte[] docx(String beforeTable, String[][] table, String afterTable) {
        try (XWPFDocument document = new XWPFDocument()) {
            document.createParagraph().createRun().setText(beforeTable);
            XWPFTable xwpfTable = document.createTable(table.length, table[0].length);
            for (int r = 0; r < table.length; r++) {
                for (int c = 0; c < table[r].length; c++) {
                    xwpfTable.getRow(r).getCell(c).setText(table[r][c]);
                }
            }
            document.createParagraph().createRun().setText(afterTable);
            ByteArrayOutputStream out = new ByteArrayOutputStream();
            document.write(out);
            return out.toByteArray();
        } catch (IOException e) {
            throw new UncheckedIOException(e);
        }
    }
 
    /**
     * 带<b>纵向合并</b>的 .docx:同一检验项目下的子项逐行展开,项目名只在第一行出现,
     * 下面几行是 {@code <w:vMerge/>} 续格。
     * <p>
     * 这正是「一个检验项目下挂若干子项」的真实报告形态:合并信息不还原的话,
     * 抽出来的是一堆看不出父子关系的平铺行。
     */
    static byte[] docxWithVerticalMerge() {
        String[][] cells = {
                {"检验项目", "子项", "检测方法", "标准值", "实测值", "判定"},
                {"粒度-筛上物比例(%)", "20目上", "ANA.MTH-000034", "0-5", "", "合格"},
                {"", "40目上", "ANA.MTH-000034", "20-40", "", "合格"},
                {"水分", "常压干燥法", "ANA.MTH-000035", "≤14.0", "", "合格"},
        };
        try (XWPFDocument document = new XWPFDocument()) {
            XWPFTable table = createFilledTable(document, cells);
            mergeDown(table, 1, 2, 0);
            return write(document);
        } catch (IOException e) {
            throw new UncheckedIOException(e);
        }
    }
 
    /**
     * 带<b>横向合并</b>的 .docx:表头第一格跨两列({@code w:gridSpan="2"})。
     * <p>
     * 只关心列位对齐这个结构问题,列名不追求与真实报告一一对应。表头行因此比数据行多占一格,
     * 数据行少的那格由适配器补齐——不补的话表头与数据行的列从此错开,模型会张冠李戴。
     */
    static byte[] docxWithGridSpanHeader() {
        String[][] cells = {
                {"检验项目", "标准值", "实测值"},
                {"外观", "无划痕", "合格"},
        };
        try (XWPFDocument document = new XWPFDocument()) {
            XWPFTable table = createFilledTable(document, cells);
            cellPropertiesOf(table.getRow(0).getCell(0)).addNewGridSpan().setVal(BigInteger.valueOf(2));
            return write(document);
        } catch (IOException e) {
            throw new UncheckedIOException(e);
        }
    }
 
    /**
     * 带页眉页脚的 .docx:抬头与落款住在正文之外,{@code getBodyElements()} 看不到它们。
     */
    static byte[] docxWithHeaderFooter(String bodyText, String headerText, String footerText) {
        try (XWPFDocument document = new XWPFDocument()) {
            document.createParagraph().createRun().setText(bodyText);
            XWPFHeaderFooterPolicy policy = document.createHeaderFooterPolicy();
            policy.createHeader(XWPFHeaderFooterPolicy.DEFAULT).createParagraph().createRun().setText(headerText);
            policy.createFooter(XWPFHeaderFooterPolicy.DEFAULT).createParagraph().createRun().setText(footerText);
            ByteArrayOutputStream out = new ByteArrayOutputStream();
            document.write(out);
            return out.toByteArray();
        } catch (IOException e) {
            throw new UncheckedIOException(e);
        }
    }
 
    /**
     * 含列头的 .xlsx。
     */
    static byte[] xlsx(String sheetName, String[][] rows) {
        try (XSSFWorkbook workbook = new XSSFWorkbook()) {
            Sheet sheet = workbook.createSheet(sheetName);
            for (int r = 0; r < rows.length; r++) {
                Row row = sheet.createRow(r);
                for (int c = 0; c < rows[r].length; c++) {
                    row.createCell(c).setCellValue(rows[r][c]);
                }
            }
            ByteArrayOutputStream out = new ByteArrayOutputStream();
            workbook.write(out);
            return out.toByteArray();
        } catch (IOException e) {
            throw new UncheckedIOException(e);
        }
    }
 
    /**
     * 带 UTF-8 BOM 的文本,用于验证 BOM 被剥掉(否则第一个单元格会带一个看不见的字符)。
     */
    static byte[] textWithBom(String text) {
        byte[] body = text.getBytes(java.nio.charset.StandardCharsets.UTF_8);
        byte[] result = new byte[body.length + 3];
        result[0] = (byte) 0xEF;
        result[1] = (byte) 0xBB;
        result[2] = (byte) 0xBF;
        System.arraycopy(body, 0, result, 3, body.length);
        return result;
    }
 
    private static byte[] save(PDDocument document) throws IOException {
        ByteArrayOutputStream out = new ByteArrayOutputStream();
        document.save(out);
        return out.toByteArray();
    }
 
    /** 建一张每格都填好文字的规则表格,后续再按需打上合并标记 */
    private static XWPFTable createFilledTable(XWPFDocument document, String[][] cells) {
        XWPFTable table = document.createTable(cells.length, cells[0].length);
        for (int r = 0; r < cells.length; r++) {
            for (int c = 0; c < cells[r].length; c++) {
                table.getRow(r).getCell(c).setText(cells[r][c]);
            }
        }
        return table;
    }
 
    /**
     * 把第 {@code fromRow} 行到第 {@code toRow} 行的第 {@code column} 列纵向合并成一个单元格。
     * <p>
     * 起始格打 {@code w:val="restart"}、后续格打不带 val 的 {@code <w:vMerge/>}(Word 的常见写法),
     * 中间格清空文字——真实文件里续格本来就是空的,合并信息全在那个标签上。
     */
    private static void mergeDown(XWPFTable table, int fromRow, int toRow, int column) {
        cellPropertiesOf(table.getRow(fromRow).getCell(column))
                .addNewVMerge().setVal(STMerge.RESTART);
        for (int r = fromRow + 1; r <= toRow; r++) {
            XWPFTableCell cell = table.getRow(r).getCell(column);
            cell.setText("");
            cellPropertiesOf(cell).addNewVMerge();
        }
    }
 
    /** 取格属性,没有 {@code <w:tcPr>} 就补一个 */
    private static CTTcPr cellPropertiesOf(XWPFTableCell cell) {
        return cell.getCTTc().isSetTcPr()
                ? cell.getCTTc().getTcPr()
                : cell.getCTTc().addNewTcPr();
    }
 
    private static byte[] write(XWPFDocument document) throws IOException {
        ByteArrayOutputStream out = new ByteArrayOutputStream();
        document.write(out);
        return out.toByteArray();
    }
 
}