diff --git a/jeecg-boot/jeecg-boot-module/jeecg-module-print/src/main/java/org/jeecg/modules/print/ai/impl/NativePrintTemplateImageAnalyzeServiceImpl.java b/jeecg-boot/jeecg-boot-module/jeecg-module-print/src/main/java/org/jeecg/modules/print/ai/impl/NativePrintTemplateImageAnalyzeServiceImpl.java index 19110aba..10e10fcd 100644 --- a/jeecg-boot/jeecg-boot-module/jeecg-module-print/src/main/java/org/jeecg/modules/print/ai/impl/NativePrintTemplateImageAnalyzeServiceImpl.java +++ b/jeecg-boot/jeecg-boot-module/jeecg-module-print/src/main/java/org/jeecg/modules/print/ai/impl/NativePrintTemplateImageAnalyzeServiceImpl.java @@ -12,9 +12,11 @@ import java.net.http.HttpRequest; import java.net.http.HttpResponse; import java.time.Duration; import java.util.Base64; +import java.util.ArrayList; import java.util.Arrays; import java.util.HashSet; import java.util.LinkedHashMap; +import java.util.List; import java.util.Map; import java.util.Set; import java.util.UUID; @@ -52,17 +54,57 @@ public class NativePrintTemplateImageAnalyzeServiceImpl implements INativePrintT "detailTables":[{"tableKey":"List1","label":"明细","fields":[{"key":"字段键","label":"列标题"}]}] } } - 规则: + + ========== 一、通用 ========== 1) 坐标 x,y,w,h 使用毫米,相对整张纸左上角;zIndex 从 1 递增。 2) elements.type 只能是:title,subtitle,text,date,pageNo,image,qrcode,barcode,table,detailTable,freeTable,reportHeader,reportFooter。 - 3) **表格三选一(极其重要)**: - - **detailTable** 或 **table**:多行**同构**明细数据(表头 + 多行重复记录,如订单行、物料清单、BOM 行)。必须有 **source**(如 List1)与 **columns**(列宽、bindField),数据按行重复。 - - **freeTable**:**套打表单/登记表**——左标签右值、多行多列**格子内容各不相同**、存在 **rowspan/colspan 合并**、或人员信息卡/申请单等非重复行列表。必须用 **freeTable**:含 **rowCount,colCount,borderWidth,borderColor,cells[]**;每个 cell 含 **row,col,rowspan,colspan,text,bindField**(可空)、align 等。**不要用 detailTable 模拟登记表**。 - 判断:若图中是「姓名/性别/身高」等字段卡、合并单元格里写「二维码」→ **freeTable**;若是「序号+产品+规格+数量」多行清单 → **detailTable**。 - 4) 从图片中读到的静态文字用 text/title;可变字段用 bindField 或表格列 bindField;params/detailTables 要覆盖所有用到的绑定键。 - 5) page 宽高与图片长宽比一致,可假定常见标签纸或 A4;单位 mm,数值合理。 - 6) 版面上单独出现「二维码」文字标签、或黑白方块二维码图案:必须用 type「qrcode」表达(value 可用示例 URL,bindField 建议 qrCodeValue),不要用 text 写「二维码」,也不要用空 src 的 image 占位方块码。 - 7) 「日期」「打印日期」「制单日期」等标签或具体年月日:用 type「date」,必须设 format(YYYY-MM-DD / YYYY年MM月DD日 等与版式一致);需要数据绑定则设 bindField 如 printDate。 + 3) 从图片读到的**静态展示文字**用 text/title/subtitle;需要数据绑定的用 bindField;params/detailTables 必须覆盖所有用到的绑定键。 + 4) page 宽高与图片长宽比一致(常见标签纸或 A4);单位 mm,数值合理。 + 5) **坐标与版心(防错位,极其重要)**: + - 原点为纸张**左上角**,**x 向右、y 向下**增大;禁止负坐标或颠倒轴向。 + - 排版前先在脑中确定**主表格块**(freeTable / table / detailTable 中外接矩形最大者)的 **(x,y,w,h)**,再布置表外元素;**禁止**在未对齐主块的情况下随意写 x,y。 + - **title / subtitle**:若在表格**上方**,应满足 **y + h ≤ 表格.y − 2~10mm**(留出空隙)。水平位置须与图中一致:若标题相对**整张纸**居中则用 **x ≈ (page.width − w) / 2**;若标题明显相对**下方表格块**居中(表宽明显小于页宽、标题盖住表的中线),则用 **x ≈ table.x + (table.w − w) / 2**,使标题与表格**左右对齐、视觉同一列宽区域**。**禁止**把标题写到表格**右缘以右**的空白区充当「居中」。 + - **date(表格外右上角)**:**x ≈ page.width − margin[1] − w**,**y** 与标题**同一水平带**(同排或略低 1~3mm),且 **y + h ≤ 表格.y − 2mm**;**禁止**把日期写到纸面竖直中部、表格下方或与标题横向严重错位。 + - 视觉自上而下为「大标题 → 日期 → 表格」时,必须 **y(标题) ≤ y(日期) + h(日期) + 小间隙**,且标题、日期元素的 **y + h** 须**明显小于**主表格的 **y**(整块落在表格外上方,与表顶至少留 2mm 间隙)。 + - 估算坐标时可按「图中该块占纸宽/纸高的比例 × page.width/page.height」换算为 mm,使表格外标题、日期与表格的**相对位置**与截图一致。 + + ========== 二、表格类型判定(极其重要,先分类再建模)========== + 若图中存在「表格」区域,必须在心里完成分类,且**同一版式块只能三选一**: + A) **detailTable**:业务**明细/清单**——多行**同构**记录(如序号、物料编码、规格、数量、单价、金额、税率等列,行结构重复)。通常有清晰表头,下方多行数据区。需 **source**(如 List1)与 **columns**(每列 key/title/bindField/width/align/**contentType**)。 + B) **table**:与 detailTable 类似但语义为主表/汇总表(也可用 source 如 mainTable);列仍同构、多行重复。 + C) **freeTable**:**套打格线表/登记表/信息卡**。满足**任一条**即优先 freeTable:① 左「姓名/生日/性别」等**标签格**与右**空白填写格**成对出现且各行语义不同;② 存在 **rowspan/colspan** 大块合并(如某一角「二维码」占位或实码跨多行多列);③ 整表为固定格子套打而非「表头+多行重复数据行」。必须用 **rowCount,colCount,borderWidth,borderColor,cells[]**;每个 cell:**row,col,rowspan,colspan,text,bindField**(可空),**contentType**,align,verticalAlign,fontSize,color,**backgroundColor** 等;可选 **colWidths**(长度=colCount)、**rowHeights**(长度=rowCount)。 + **反例(易错)**:仅有「姓名、年龄、生日、学历…」字段行 + 角上二维码合并格 → **绝不是** detailTable;**绝不是**「单列竖向多行」伪明细表。 + **禁止**用 detailTable/table 去模拟明显的 freeTable;禁止把「人员信息卡」误建成横向多列明细表。 + D) **混合版式**:上方为套打区(二维码/日期/说明格)、下方为**多行同构明细**时,必须拆成 **freeTable(上) + detailTable/table(下)** 两个元素,**禁止**只用一张 detailTable 吞掉上方版式,也**禁止**把整张明细误改成单行 freeTable。 + + ========== 三、明细表 / 普通表(table、detailTable)========== + 0) **勿与登记表混淆**:若首行是「姓名/年龄」等**字段名标签**且下方并非多行**同结构**重复记录,而是套打格线,则应归为 **freeTable**,不要建成 detailTable。 + 1) **默认单级表头(极其重要)**:**enableMultiHeader 默认为 false**,**不要输出**空的 **headerConfig** 或「占行无字」的假多级表头。仅当图中表头区域**肉眼可见至少两行不同的文字表头**(如上行大类、下行具体列名,且两行都有字)才设 **enableMultiHeader: true**。 + 2) **列名必须可读**:**columns** 中每一列的 **title** 须从图中表头**逐字抄录**(如「序号」「名称」「性别」),**禁止**留空或只写 field 键名。若只有一行表头,**禁止**臆造第二行空表头。 + 3) **多级表头(仅当真多级时)**:输出 **headerConfig** 时,**cells[] 里每个格子的 title 必须与图中可见文字一致**,禁止整行空白;**rowCount/colCount** 与格子数量、**columns** 叶子列数一致。若无法读清多级结构,宁可 **enableMultiHeader:false** 只做单行表头。 + 4) **合并表头**:若表头格存在跨行/跨列(视觉合并单元格),在 headerConfig.cells 中正确写 **rowspan/colspan**;列定义 columns 与表头叶子列一一对应,bindField 对齐到数据字段。 + 5) **列 contentType**:纯文字列用 **"contentType":"text"**;金额/数值列用 **amount** 或 **number**;列内是图片用 **image**;列内是条码用 **barcode**。若业务是**普通字符串展示**,一律 **text**,不要用 qrcode 类型列除非该列整列都是二维码图。 + 6) **tableHeightMode**:长明细可用 **autoPage**;固定行分页用 **fixedRows** 并写 fixedRows 数字。 + + ========== 四、自由表格(freeTable)========== + 1) **网格与坐标**:**row、col 从 0 开始**,与 HTML 表格一致。**rowCount、colCount** = 按最外边框内**完整纵横格线**数出来的**逻辑行数、列数**(与是否存在合并无关)。例如常见「6 行 × 4 列」人员登记表,即 rowCount=6,colCount=4,**不要**因第一行两列占位不同而把整表改成 3 列或更少列。 + 2) **合并单元格(锚点唯一)**:每个被合并的矩形区域在 cells 里**只输出一条**记录:取该区域**左上角**那一格的 (row,col),写 **rowspan、colspan** 覆盖所占行数与列数。**被合并覆盖到的从属格位不要再输出 cell**,否则会导致格位重叠、版式错乱。 + 3) **登记表 + 角区二维码合并格(典型)**:多行左侧为「性别/身高/体重」等标签、对侧为填写区时,若某一角有一块**跨多行多列**的合并格且内容为「二维码」字样或二维码图案,则该合并格**仅一个锚点**(左上角格坐标)+ **rowspan、colspan** **严格等于图中该码区所占格数**,**禁止**用超大 rowspan 吞掉左侧「性别/身高/体重」等独立标签列,也**禁止**用顶层 **type:"qrcode"** 大块叠在表上;格内用 **contentType:"qrcode"** + **bindField** 即可。 + 4) **标签格 vs 值格**:常见 **标签|值|标签|值** 四列交替(以图中列位置为准,勿机械套用奇偶列)。**固定标签**格写中文标签到 **text**、**contentType:"text"**、一般无 **bindField**;**待填值**格 **contentType:"text"** + **bindField**(英文驼峰或下划线)。图中「示例_xxx」「样例_xxx」等占位视为动态字段,写入对应值格 **bindField**(可与 dataBinding.params 或 fieldMap 键一致)。 + 5) **表格外标题与日期**:若「标题」居中、日期在右上且**明显在表格边框线之外**(表格外上方),请用独立元素 **type:"title"** 与 **type:"date"**(带 format),**不要**把它们强行做成 freeTable 的第 0 行(除非视觉上标题确实画在表内单元格里)。**x,y** 须满足第一节「坐标与版心」:标题与**主表格块水平居中对齐**(表宽小于页宽时 **title.x ≈ table.x + (table.w − title.w)/2**),日期靠纸右缘,二者均在表格 **y** 之上。 + 6) **列宽、竖线与行高(防列线跑偏)**:**colWidths** 长度须等于 **colCount**,与图中**竖分割线间距比例**一致,**sum(colWidths) ≈ freeTable 元素 w**(允许 1~2mm);**rowHeights** 长度须等于 **rowCount** 且与各行视觉高度成比例。勿少列、勿吞并视觉独立的窄列。 + 7) **填充色**:非白底格写 **backgroundColor**;白底可 **#ffffff** 或省略。 + 8) **表格内的「二维码」**(极其重要): + - 字样或图案**在格线内** → 该 cell **contentType:"qrcode"** + bindField;**禁止**顶层再叠 **type:"qrcode"**。 + - **仅在表格外**独立一块时 → 顶层 **type:"qrcode"**。 + 9) 自由表单元格 **contentType**:text | image | qrcode | barcode | number | amount;普通字符串绑定一律 **text**。 + + ========== 五、文字与日期、独立二维码 ========== + 1) 顶层「日期」「打印日期」或具体年月日:用 **type:"date"**,必须 **format**(YYYY-MM-DD / YYYY年MM月DD日 等);可 **bindField**。 + 2) **独立**二维码图块(不在表格格内):**type:"qrcode"**,value 可用示例 URL,bindField 建议 qrCodeValue;不要用空 src 的 image 冒充。 + 3) 普通说明文字不要用 qrcode 类型表达。 + + 请严格按以上规则输出 JSON,以提高与原生设计器(table/detailTable/freeTable)的一致性。 """; private final ObjectMapper objectMapper; @@ -265,6 +307,10 @@ public class NativePrintTemplateImageAnalyzeServiceImpl implements INativePrintT tryConvertMisclassifiedListTableToFreeTable(e); type = e.path("type").asText("text"); } + if ("freeTable".equals(type)) { + // 格内「二维码」文案须 contentType:qrcode;先于 ensureFreeTableShape 写入 + upgradeFreeTableCellsSemantics(e); + } if ("table".equals(type) || "detailTable".equals(type)) { ensureTableShape(e); } else if ("freeTable".equals(type)) { @@ -284,6 +330,8 @@ public class NativePrintTemplateImageAnalyzeServiceImpl implements INativePrintT } } + // 若 freeTable 格内已是二维码,去掉叠在同一区域内的顶层 qrcode,避免渲染「吃掉」表格或丢格 + removeStandaloneQrDateRedundantWithFreeTable(elements); // AI 常同时输出「二维码/日期」文案 + 图形,升级后易重叠重复,此处按包围盒与绑定键去重 dedupeOverlappingSemanticElements(elements); @@ -534,8 +582,12 @@ public class NativePrintTemplateImageAnalyzeServiceImpl implements INativePrintT if (looksLikeCommerceDetailColumns(cols)) { return; } + // 典型清单/明细表头:勿把整张表误改成单行 freeTable,否则下方多行体与二维码区会丢失 + if (hasColumnTitleContaining(cols, "序号") || hasColumnTitleContaining(cols, "项次")) { + return; + } int personalHits = countPersonalFormLabels(cols); - if (personalHits < 3) { + if (personalHits < 5) { return; } ArrayNode colsCopy = cols.deepCopy(); @@ -608,6 +660,15 @@ public class NativePrintTemplateImageAnalyzeServiceImpl implements INativePrintT return n; } + private static boolean hasColumnTitleContaining(ArrayNode cols, String needle) { + for (JsonNode c : cols) { + if (c.isObject() && needle.equals(c.path("title").asText("").trim())) { + return true; + } + } + return false; + } + private static void stripTableOnlyKeys(ObjectNode el) { for (String k : TABLE_ONLY_KEYS) { el.remove(k); @@ -685,6 +746,132 @@ public class NativePrintTemplateImageAnalyzeServiceImpl implements INativePrintT } } + /** 自由表单元格:格内「二维码」/日期类文案补全 contentType 与 bindField(顶层 upgrade 扫不到 cells) */ + private void upgradeFreeTableCellsSemantics(ObjectNode ft) { + JsonNode cellsNode = ft.get("cells"); + if (cellsNode == null || !cellsNode.isArray()) { + return; + } + for (JsonNode n : cellsNode) { + if (!n.isObject()) { + continue; + } + ObjectNode cell = (ObjectNode) n; + String text = cell.path("text").asText("").trim(); + if (isQrCodeLabelText(text)) { + cell.put("contentType", "qrcode"); + if (!cell.has("bindField") || StringUtils.isBlank(cell.path("bindField").asText())) { + cell.put("bindField", "qrCode"); + } + cell.put("text", ""); + if (!cell.has("verticalAlign") || StringUtils.isBlank(cell.path("verticalAlign").asText())) { + cell.put("verticalAlign", "middle"); + } + if (!cell.has("align") || StringUtils.isBlank(cell.path("align").asText())) { + cell.put("align", "center"); + } + } else if (StringUtils.isNotBlank(text) && isDateSemanticText(text)) { + if (!cell.has("bindField") || StringUtils.isBlank(cell.path("bindField").asText())) { + cell.put("bindField", "printDate"); + } + if (!cell.has("contentType") || StringUtils.isBlank(cell.path("contentType").asText())) { + cell.put("contentType", "text"); + } + } + } + } + + /** + * 当 freeTable 格内已有二维码/日期语义时,删除叠在同一区域内的顶层 qrcode、date,避免遮挡表格或造成「丢失格」错觉。 + */ + private void removeStandaloneQrDateRedundantWithFreeTable(ArrayNode elements) { + List fts = new ArrayList<>(); + for (JsonNode n : elements) { + if (n.isObject() && "freeTable".equals(n.path("type").asText(""))) { + fts.add((ObjectNode) n); + } + } + if (fts.isEmpty()) { + return; + } + for (int i = elements.size() - 1; i >= 0; i--) { + JsonNode n = elements.get(i); + if (!n.isObject()) { + continue; + } + ObjectNode e = (ObjectNode) n; + String t = e.path("type").asText(""); + if (!"qrcode".equals(t) && !"date".equals(t)) { + continue; + } + for (ObjectNode ft : fts) { + boolean hasQr = freeTableHasQrLikeCell(ft); + boolean hasDate = freeTableHasDateLikeCell(ft); + if ("qrcode".equals(t) && hasQr) { + double overlap = rectOverlapRatio(e, ft); + if (overlap >= 0.04 || centerInsideFreeTableBounds(e, ft)) { + elements.remove(i); + break; + } + } + if ("date".equals(t) && hasDate) { + double overlap = rectOverlapRatio(e, ft); + if (overlap >= 0.08 || centerInsideFreeTableBounds(e, ft)) { + elements.remove(i); + break; + } + } + } + } + } + + private static boolean freeTableHasQrLikeCell(ObjectNode ft) { + JsonNode cells = ft.get("cells"); + if (cells == null || !cells.isArray()) { + return false; + } + for (JsonNode c : cells) { + if (!c.isObject()) { + continue; + } + if ("qrcode".equalsIgnoreCase(c.path("contentType").asText("").trim())) { + return true; + } + String tx = c.path("text").asText(""); + if (StringUtils.isNotBlank(tx) && tx.contains("二维码")) { + return true; + } + } + return false; + } + + private boolean freeTableHasDateLikeCell(ObjectNode ft) { + JsonNode cells = ft.get("cells"); + if (cells == null || !cells.isArray()) { + return false; + } + for (JsonNode c : cells) { + if (!c.isObject()) { + continue; + } + String tx = c.path("text").asText(""); + if (isDateSemanticText(tx)) { + return true; + } + } + return false; + } + + private static boolean centerInsideFreeTableBounds(ObjectNode e, ObjectNode ft) { + double fx = ft.path("x").asDouble(); + double fy = ft.path("y").asDouble(); + double fw = Math.max(ft.path("w").asDouble(), 0.01); + double fh = Math.max(ft.path("h").asDouble(), 0.01); + double cx = e.path("x").asDouble() + e.path("w").asDouble() / 2.0; + double cy = e.path("y").asDouble() + e.path("h").asDouble() / 2.0; + return cx >= fx - 1 && cx <= fx + fw + 1 && cy >= fy - 1 && cy <= fy + fh + 1; + } + /** 文案为「二维码」或极短含二维码提示 */ private boolean isQrCodeLabelText(String text) { if (StringUtils.isBlank(text)) { diff --git a/jeecgboot-vue3/src/views/print/template/native/NativePrintDesigner.vue b/jeecgboot-vue3/src/views/print/template/native/NativePrintDesigner.vue index c0d18c12..0207e6a4 100644 --- a/jeecgboot-vue3/src/views/print/template/native/NativePrintDesigner.vue +++ b/jeecgboot-vue3/src/views/print/template/native/NativePrintDesigner.vue @@ -150,13 +150,21 @@ +