diff --git a/inspect-base/inspect-base-core/src/main/java/com/inspect/base/core/sftp/SftpClient.java b/inspect-base/inspect-base-core/src/main/java/com/inspect/base/core/sftp/SftpClient.java
index b6ca882..ae198b9 100644
--- a/inspect-base/inspect-base-core/src/main/java/com/inspect/base/core/sftp/SftpClient.java
+++ b/inspect-base/inspect-base-core/src/main/java/com/inspect/base/core/sftp/SftpClient.java
@@ -113,7 +113,7 @@ public class SftpClient {
ftps.setFileType(FTPSClient.BINARY_FILE_TYPE);
sftpFactory.init();
}
-
+ ftps.setControlEncoding("UTF-8");
ftps.enterLocalPassiveMode();
ftps.enterLocalPassiveMode();
ftps.setFileTransferMode(FTPSClient.STREAM_TRANSFER_MODE);
diff --git a/inspect-main/inspect-main-api/src/main/java/com/inspect/api/service/ReportUploadService.java b/inspect-main/inspect-main-api/src/main/java/com/inspect/api/service/ReportUploadService.java
index f34f5de..f02fa4d 100644
--- a/inspect-main/inspect-main-api/src/main/java/com/inspect/api/service/ReportUploadService.java
+++ b/inspect-main/inspect-main-api/src/main/java/com/inspect/api/service/ReportUploadService.java
@@ -15,6 +15,7 @@ import java.io.InputStream;
import java.io.InputStreamReader;
import java.net.HttpURLConnection;
import java.net.URL;
+import java.nio.charset.StandardCharsets;
/**
* 巡检报告上传服务(外部平台《巡检报告接口文档 v1.0.0》)
@@ -127,8 +128,9 @@ public class ReportUploadService {
out.write(reportInfoJson.getBytes("UTF-8"));
out.writeBytes("\r\n");
// file 字段
- out.writeBytes("--" + boundary + "\r\n");
- out.writeBytes("Content-Disposition: form-data; name=\"file\"; filename=\"" + fileName + "\"\r\n");
+ out.write(("--" + boundary + "\r\n").getBytes(StandardCharsets.UTF_8));
+ String fileHeader = "Content-Disposition: form-data; name=\"file\"; filename=\"" + fileName + "\"\r\n";
+ out.write(fileHeader.getBytes(StandardCharsets.UTF_8));
out.writeBytes("Content-Type: application/octet-stream\r\n\r\n");
try (InputStream in = new ByteArrayInputStream(fileBytes)) {
byte[] buf = new byte[8192];
diff --git a/inspect-main/inspect-main-task/src/main/java/com/inspect/resultmain/controller/DonghuanReportExportController.java b/inspect-main/inspect-main-task/src/main/java/com/inspect/resultmain/controller/DonghuanReportExportController.java
index b0b89f4..fb570a6 100644
--- a/inspect-main/inspect-main-task/src/main/java/com/inspect/resultmain/controller/DonghuanReportExportController.java
+++ b/inspect-main/inspect-main-task/src/main/java/com/inspect/resultmain/controller/DonghuanReportExportController.java
@@ -59,11 +59,31 @@ import java.util.zip.ZipOutputStream;
*
页面:A4(11906×16838 twips),版心 上2098/右1474/下1984/左1587
* 封面:42pt 仿宋_GB2312 大标题 + 分页;第二页 logo + 方正小标宋 22pt 深蓝标题(不加粗)
* + 仿宋_GB2312 16pt 浅蓝副标题 + 封面签署表(初审/校对/审核/巡检日期)
- * 标题:报告标题 方正小标宋简体 22pt(二号)不加粗;一级标题(一、…):黑体 16pt(三号);
- * 二级标题((一)…):仿宋_GB2312 16pt(三号);表头/栏头:深蓝 1F4E79 底白字加粗仿宋_GB2312 14pt(四号);
- * 表格正文:仿宋_GB2312 12pt(小四)居中
- * 表格边框:B4C6E7 细线(sz=0),单元格垂直居中,表格文字居中、单倍行距,行高不低于 454 twips
- * 巡检详情:每个点位一张 8 行 × 4 列详情表,末行基准图/缺陷图
+ * 标题:报告标题 方正小标宋简体 22pt(二号)不加粗;一级标题:黑体 16pt(三号);
+ * 二级标题:仿宋_GB2312 16pt(三号)
+ * 章节编号(与模板一致):标题使用 Word 多级自动编号 —— 一级「一、」、二级「(一)」、
+ * 三级「1.」、四级「(1)」(见 {@link #createHeadingNumbering}),标题文字里不再写手写序号;
+ * 各级标题带 outlineLvl 大纲级别,Word 导航窗格与「引用 → 目录」可直接生成目录
+ * 表格宽度:通篇统一 {@link #TABLE_W} = 8844 twips(= 版心宽,与模板一致),
+ * 列宽由 {@code COLS_*} 常量按总和 8844 分配,tblLayout=fixed、表格居中,
+ * 保证所有表格左右边界完全对齐且不越出版心
+ * 表头/栏头:深蓝 1F4E79 底 + 白字 + 加粗 + 四号 14pt + 居中;
+ * 「栏头」= 巡检详情表内用于标注取值的单元格(巡检点位名称 / 巡检时间 / 区域名称 /
+ * 基准图 / 缺陷图 等),与表头同款样式,便于与右侧取值区分
+ * 表格正文:仿宋_GB2312 12pt(小四)、居中、单元格垂直居中;行高不设最小值(与模板一致),
+ * 行高由「行距 + 上下边距」自然决定:实测模板单行数据行 = 312 + 120 = 432 twips;
+ * 若写 w:trHeight 最小值,WPS 会在该值之上再加边距,单行数据行会比模板高约 7 磅
+ * 单元格内边距与行距(按模板实测值对齐):表头/栏头 上下 211、左右 120、行高贴合文字(362),
+ * 折行时两行行首距 624(= 模板行网格 2 行);正文 上下 60、左右 120、行高 312(15.6 磅/行);
+ * 封面签署表 上下 60、左 150、右 120、行高固定 560(28 磅)
+ * —— 见 {@link #LAYOUT_HEAD} / {@link #LAYOUT_BODY} / {@link #LAYOUT_COVER}。
+ * 模板的表头用「624 行框 + 上下 80」,文字贴行框顶、看起来偏上;本报告把行框收到贴合文字、
+ * 差额补给内边距,行高与行首距与模板相同而文字真正垂直居中
+ * 文字换行:单元格文字按列宽计算换行 —— 表头按语义/均衡拆分(如「区域/名称」
+ * 「巡检点位/名称」「巡检点位数量/(个)」),正文避免末行只剩 1 个字的孤字,
+ * 并遵守避头尾、不拆开英文数字串(见 {@link #wrapLines})
+ * 表格边框:B4C6E7 细线(sz=0),与模板 tcBorders 一致
+ * 巡检详情:每个点位一张 8 行 × 4 列详情表,末行基准图/缺陷图(图片按单元格内容宽等比缩放)
*
*
* 已知不确定点(待归档时调整,均以 TODO 标注):
@@ -84,6 +104,17 @@ public class DonghuanReportExportController extends BaseController {
private static final String FONT_HEAD = "黑体";
/** 报告标题 */
private static final String FONT_TITLE = "方正小标宋简体";
+ /**
+ * 表头/栏头字体:黑体。
+ *
+ * 为什么表头不用「仿宋_GB2312 加粗」:仿宋_GB2312 本身没有独立粗体字面,docx 里嵌入的
+ * 「仿宋_GB2312 粗体」槽位({@code w:embedBold})指向的仍是同一份常规字体
+ * (见 {@link ReportFontEmbedder}),Word 打开时不会再做加粗,表头看起来与正文一样细;
+ * 黑体嵌入的是真正粗重的字面(simhei),且加粗槽位指向它,因此表头统一使用
+ * 黑体 + 加粗,在任何查看端都呈现为粗体表头(也与「一、二、三」等黑体小标题一致)。
+ * 若要恢复「仿宋_GB2312 加粗」,只需把本常量改为 {@link #FONT_BODY}。
+ */
+ private static final String FONT_TABLE_HEAD = FONT_BODY;
/** 深蓝:标题 / 表头底色 */
private static final String COLOR_DARK = "1F4E79";
/** 浅蓝:封面副标题 */
@@ -101,15 +132,132 @@ public class DonghuanReportExportController extends BaseController {
private static final int SZ_HEAD_FOURTH = 28;
/** 一级/二级标题 16pt(半磅 32)三号 */
private static final int SZ_HEAD_THIRD = 32;
- /** 行高 atLeast(twips) */
- private static final int ROW_H = 454;
- /** 单元格左右边距(twips) */
- private static final int CELL_MAR = 108;
+ /**
+ * 单元格左右内边距(twips):与模板 tcMar 一致(模板表头/正文均为 120)。
+ * 注意 {@link #contentWidth} 用它算可用文字宽度,列宽分配与之配套。
+ */
+ private static final int CELL_MAR = 120;
+ /**
+ * 表头/栏头文字自身行高(twips):14pt 仿宋_GB2312 的行框高,WPS 实测 362(18.1 磅)。
+ * 表头段落用它作为行高(atLeast,贴合文字),而不是直接用下面的 624。
+ */
+ private static final int HEAD_TEXT_LINE = 362;
+ /**
+ * 表头行网格高度(twips):模板文档网格 {@code linePitch=312},14pt 行 (>312) 被吸附到
+ * 2 行网格 = 624(31.2 磅),实测模板「识别 / 类型」两行行首距 = 624。
+ */
+ private static final int CELL_LINE_HEAD = 624;
+ /** 表头两行之间的段后间距(twips)= 624 − 362:使两行行首距仍为模板的 624,文字块整体仍居中 */
+ private static final int HEAD_LINE_GAP = CELL_LINE_HEAD - HEAD_TEXT_LINE;
+ /**
+ * 表头/栏头单元格上下内边距(twips)= 模板 80 + (624 − 362) / 2 = 211。
+ *
+ * 模板的做法是「624 高的行框 + 上下 80 内边距」:行框远高于文字自身行高(362),
+ * 文字贴着行框顶部,于是实测模板表头文字上留白仅 85 twips、下留白 342 twips —— 明显偏上。
+ * 本报告把行框收到贴合文字、把差额补给上下内边距,得到与模板相同的行高
+ * (单行 790 vs 模板 789、两行 1415 vs 模板 1413、两行行首距 625 vs 模板 624),
+ * 同时文字上下留白对称(216 / 212),即真正垂直居中。
+ */
+ private static final int CELL_MAR_TB_HEAD = 80 + (CELL_LINE_HEAD - HEAD_TEXT_LINE) / 2;
+ /** 正文单元格上下内边距(twips):与模板实测一致(60) */
+ private static final int CELL_MAR_TB_BODY = 60;
+ /**
+ * 正文单元格段落行高(twips):模板文档网格 {@code linePitch=312}(15.6 磅/行),
+ * 12pt 正文吸附 1 行网格 = 312(实测模板数据行高 312 + 上下边距 120 = 432 twips)。
+ */
+ private static final int CELL_LINE = 312;
+ /** 封面签署表单元格上下内边距(twips):模板实测 60 */
+ private static final int COVER_MAR_TOP_BOTTOM = 60;
+ /** 封面签署表左侧内边距(twips):模板实测 150(左比右宽,是为签字留白) */
+ private static final int COVER_MAR_LEFT = 150;
+ /** 封面签署表右侧内边距(twips):模板实测 120 */
+ private static final int COVER_MAR_RIGHT = 120;
+ /** 封面签署表段落行高(twips):模板为固定值 560(28 磅),实测行高 560 + 上下边距 120 = 680 */
+ private static final int COVER_CELL_LINE = 560;
- /** 巡检详情图片尺寸(EMU,与模板一致) */
+ /**
+ * 单元格排版参数(内边距 + 段落行高/规则 + 表头行间间距)。
+ *
+ * 三套参数与机器人巡检报告模板逐一实测对齐:表头/栏头、正文数据、封面签署表。
+ */
+ private static final class CellLayout {
+ final int marTop, marBottom, marLeft, marRight, lineTw;
+ final STLineSpacingRule.Enum lineRule;
+ /** 多行单元格中,除最后一行外每行段后间距(twips):表头用它撑出模板的两行行首距 624 */
+ final int lineGapTw;
+
+ CellLayout(int marTop, int marBottom, int marLeft, int marRight,
+ int lineTw, STLineSpacingRule.Enum lineRule, int lineGapTw) {
+ this.marTop = marTop;
+ this.marBottom = marBottom;
+ this.marLeft = marLeft;
+ this.marRight = marRight;
+ this.lineTw = lineTw;
+ this.lineRule = lineRule;
+ this.lineGapTw = lineGapTw;
+ }
+ }
+
+ /**
+ * 表头/栏头:上下 211、左右 120,行高贴合文字(362),两行之间 262 →
+ * 单行行高 790 / 两行 1415 / 两行行首距 625,与模板实测(789 / 1413 / 624)一致,且文字垂直居中。
+ */
+ private static final CellLayout LAYOUT_HEAD = new CellLayout(CELL_MAR_TB_HEAD, CELL_MAR_TB_HEAD,
+ CELL_MAR, CELL_MAR, HEAD_TEXT_LINE, STLineSpacingRule.AT_LEAST, HEAD_LINE_GAP);
+ /** 正文数据:上下 60、左右 120,行高 312(模板实测) */
+ private static final CellLayout LAYOUT_BODY = new CellLayout(CELL_MAR_TB_BODY, CELL_MAR_TB_BODY,
+ CELL_MAR, CELL_MAR, CELL_LINE, STLineSpacingRule.AT_LEAST, 0);
+ /** 封面签署表:上下 60、左 150、右 120,行高固定 560(模板实测) */
+ private static final CellLayout LAYOUT_COVER = new CellLayout(COVER_MAR_TOP_BOTTOM, COVER_MAR_TOP_BOTTOM,
+ COVER_MAR_LEFT, COVER_MAR_RIGHT, COVER_CELL_LINE, STLineSpacingRule.EXACT, 0);
+
+ // ---------------------------- 表格宽度(通篇统一) ----------------------------
+ /**
+ * 正文表格统一宽度(twips):版心宽 = A4 11906 - 左 1587 - 右 1474 = 8845,
+ * 取 8844(与机器人巡检报告模板一致)。所有正文表格的 tblW 与 tblGrid 列宽之和
+ * 都必须等于该值,保证通篇表格左右边界完全对齐、且不越出版心。
+ */
+ private static final int TABLE_W = 8844;
+ /**
+ * 封面签署表列宽(4 行 × 2 列,合计 4686,居中,与模板一致)。
+ * 封面签署表是签名栏,不参与正文表格宽度统一。
+ */
+ private static final int[] COLS_COVER = {1800, 2886};
+ /** 一、巡检基本情况(4 列):电站名称 | 值 | 位置 | 值(合计 8844) */
+ private static final int[] COLS_BASIC = {1783, 2591, 1874, 2596};
+ /** 二、巡检状态说明(3 列):状态 | 说明 | 典型场景(合计 8844) */
+ private static final int[] COLS_STATUS = {1787, 4017, 3040};
+ /** 三(一)巡检统计(2 列):巡检状态 | 数量(个)(合计 8844) */
+ private static final int[] COLS_STAT = {4217, 4627};
+ /**
+ * 三(二)缺陷汇总表(9 列):
+ * 序号 | 区域名称 | 间隔名称 | 设备名称 | 部件名称 | 巡检点位名称 | 巡检结果 | 状态 | 图片信息
+ * (合计 8844。左右内边距 120×2 后仍保证:区域/间隔/设备/部件 列 12pt 每行 3 个汉字(数据少折行)、
+ * 巡检点位名称 列 14pt 表头 4+2、其余窄列 14pt 表头 2 个字不卡边)
+ */
+ private static final int[] COLS_SUMMARY = {840, 970, 970, 970, 970, 1400, 880, 840, 1004};
+ /** 四、巡检详情(4 列):栏头 | 值 | 栏头 | 值(合计 8844) */
+ private static final int[] COLS_DETAIL = {1714, 2781, 1627, 2722};
+
+ /** 1 twip = 1/20 磅 = 635 EMU */
+ private static final long EMU_PER_TWIP = 635L;
+
+ /** 巡检详情图片原始尺寸(EMU)——仅用于等比缩放取宽高比 */
private static final long IMG_PATROL_W = 1711325L, IMG_PATROL_H = 960755L;
private static final long IMG_DEFECT_W = 1629410L, IMG_DEFECT_H = 916940L;
+ // ---------------------------- 换行(避头尾 / 均衡) ----------------------------
+ /** 不能出现在行首的字符(避头尾规则):收尾标点、右括号等 */
+ private static final String NO_LINE_START = ",。、;:?!)〕】》」』〉”’%‰℃·,.;:?!)]}";
+ /** 不能出现在行尾的字符:开括号等 */
+ private static final String NO_LINE_END = "(〔【《「『〈“‘([{";
+ /**
+ * 「类别词」后缀:4 字表头按 2+2 拆分、6 字表头按 4+2 拆分,
+ * 让「巡检点位 / 名称」「识别 / 类型」这类拆分符合语义(与模板表头一致)
+ */
+ private static final String[] TAIL_WORDS = {"名称", "结果", "状态", "类型", "标准",
+ "信息", "时间", "数量", "等级", "照片", "原图", "判定", "场景", "意见", "读数"};
+
@Value("${sftp.basePath}")
private String basePath;
@@ -460,12 +608,14 @@ public class DonghuanReportExportController extends BaseController {
// 嵌入报告字体(仿宋_GB2312/黑体/方正小标宋),docx 自带字体,查看方无需安装
reportFontEmbedder.embed(doc);
setupPage(doc);
+ // 章节标题多级自动编号(与机器人模板一致:一、/(一)/1./(1))
+ BigInteger headingNumId = createHeadingNumbering(doc);
buildCoverPage1(doc); // 第1页:42pt 大标题 + 分页
buildCoverPage2(doc, report); // 第2页:logo + 报告标题 + 副标题 + 签署表
- buildSectionBasic(doc, report); // 一、巡检基本情况
- buildSectionStatus(doc); // 二、巡检状态说明
- buildSectionOverview(doc, report, tempFileMap); // 三、巡检结果概览(缺陷汇总含图片信息)
- buildSectionDetail(doc, report, tempFileMap); // 四、巡检详情
+ buildSectionBasic(doc, report, headingNumId); // 一、巡检基本情况
+ buildSectionStatus(doc, headingNumId); // 二、巡检状态说明
+ buildSectionOverview(doc, report, tempFileMap, headingNumId); // 三、巡检结果概览
+ buildSectionDetail(doc, report, tempFileMap, headingNumId); // 四、巡检详情
return doc;
}
@@ -477,6 +627,87 @@ public class DonghuanReportExportController extends BaseController {
}
}
+ // ---------------------------------------------------------------------------
+ // 章节标题:多级自动编号 + 大纲级别(与机器人巡检报告模板一致)
+ // ---------------------------------------------------------------------------
+
+ /** 章节标题编号级别:0=一级「一、」,1=二级「(一)」,2=三级「1.」,3=四级「(1)」 */
+ private static final int LVL_1 = 0, LVL_2 = 1, LVL_3 = 2, LVL_4 = 3;
+ /** 各级编号文字(%n 为第 n 级序号占位符),与模板 numbering 定义一致 */
+ private static final String[] HEADING_NUM_TEXT = {"%1、", "(%2)", "%3.", "(%4)"};
+ /** 各级编号格式:一、/(一)用中文数字,1./(1)用阿拉伯数字(与模板一致) */
+ private static final STNumberFormat.Enum[] HEADING_NUM_FMT = {
+ STNumberFormat.CHINESE_COUNTING, STNumberFormat.CHINESE_COUNTING,
+ STNumberFormat.DECIMAL, STNumberFormat.DECIMAL};
+ /** 各级编号字体(模板:一级黑体,其余仿宋_GB2312) */
+ private static final String[] HEADING_NUM_FONT = {FONT_HEAD, FONT_BODY, FONT_BODY, FONT_BODY};
+
+ /**
+ * 创建章节标题的多级自动编号定义(与机器人巡检报告模板一致),返回 numId。
+ *
+ * 编号(一、/(一)/1./(1))由 Word 依据本定义自动生成,标题文字里不再写手写序号;
+ * 各级标题同时写入 outlineLvl(见 {@link #setHeadingNumbering}),
+ * Word 导航窗格与「引用 → 目录」可直接生成带页码的目录。
+ */
+ private BigInteger createHeadingNumbering(XWPFDocument doc) {
+ XWPFNumbering numbering = doc.createNumbering();
+ CTAbstractNum ctAbstractNum = CTAbstractNum.Factory.newInstance();
+ ctAbstractNum.setAbstractNumId(BigInteger.ZERO);
+ ctAbstractNum.addNewMultiLevelType().setVal(STMultiLevelType.MULTILEVEL);
+ for (int level = 0; level < HEADING_NUM_TEXT.length; level++) {
+ CTLvl lvl = ctAbstractNum.addNewLvl();
+ lvl.setIlvl(BigInteger.valueOf(level));
+ lvl.addNewStart().setVal(BigInteger.ONE);
+ lvl.addNewNumFmt().setVal(HEADING_NUM_FMT[level]);
+ lvl.addNewLvlText().setVal(HEADING_NUM_TEXT[level]);
+ lvl.addNewLvlJc().setVal(STJc.LEFT);
+ // 编号后不补制表符/空格(模板为 nothing),否则「一、」与标题之间会出现大段空隙
+ lvl.addNewSuff().setVal(STLevelSuffix.NOTHING);
+ CTInd ind = lvl.addNewPPr().addNewInd();
+ ind.setLeft(BigInteger.ZERO);
+ ind.setFirstLine(BigInteger.ZERO);
+ // 编号字符的字体字号(Word 优先取这里的设置,缺失时会退到正文样式而变小)
+ CTRPr numRPr = lvl.addNewRPr();
+ CTFonts fonts = numRPr.addNewRFonts();
+ String numFont = HEADING_NUM_FONT[level];
+ fonts.setAscii(numFont);
+ fonts.setHAnsi(numFont);
+ fonts.setEastAsia(numFont);
+ fonts.setCs(numFont);
+ numRPr.addNewSz().setVal(BigInteger.valueOf(SZ_HEAD_THIRD));
+ numRPr.addNewSzCs().setVal(BigInteger.valueOf(SZ_HEAD_THIRD));
+ }
+ BigInteger abstractNumId = numbering.addAbstractNum(new XWPFAbstractNum(ctAbstractNum));
+ return numbering.addNum(abstractNumId);
+ }
+
+ /**
+ * 章节标题段落:多级自动编号 + 大纲级别;一级标题黑体三号,二级及以下仿宋_GB2312 三号(与模板一致)。
+ *
+ * @param level 编号级别,取 {@link #LVL_1} / {@link #LVL_2} / {@link #LVL_3} / {@link #LVL_4}
+ */
+ private XWPFParagraph addSectionHeading(XWPFDocument doc, String text, int level,
+ BigInteger numId, int before, int after) {
+ String font = (level == LVL_1) ? FONT_HEAD : FONT_BODY;
+ XWPFParagraph p = addPara(doc, text, font, SZ_HEAD_THIRD, false, false, null, null, before, after);
+ setHeadingNumbering(p, level, numId);
+ setFirstLineIndentOneChar(p, SZ_HEAD_THIRD); // 标题首行缩进 1 字符
+ setKeepNext(p);
+ return p;
+ }
+
+ /** 段落写入多级编号(numPr)与大纲级别(outlineLvl,供导航窗格/目录使用) */
+ private void setHeadingNumbering(XWPFParagraph p, int level, BigInteger numId) {
+ CTPPr pPr = p.getCTP().isSetPPr() ? p.getCTP().getPPr() : p.getCTP().addNewPPr();
+ CTNumPr numPr = pPr.isSetNumPr() ? pPr.getNumPr() : pPr.addNewNumPr();
+ CTDecimalNumber ilvl = numPr.isSetIlvl() ? numPr.getIlvl() : numPr.addNewIlvl();
+ ilvl.setVal(BigInteger.valueOf(level));
+ CTDecimalNumber numIdNum = numPr.isSetNumId() ? numPr.getNumId() : numPr.addNewNumId();
+ numIdNum.setVal(numId);
+ CTDecimalNumber outlineLvl = pPr.isSetOutlineLvl() ? pPr.getOutlineLvl() : pPr.addNewOutlineLvl();
+ outlineLvl.setVal(BigInteger.valueOf(level));
+ }
+
/**
* 设置段落首行缩进 1 字符。
* 写 w:firstLineChars=100(Word/WPS 原生「首行缩进:1 字符」,随字号自动折算 1 个汉字宽),
@@ -554,108 +785,100 @@ public class DonghuanReportExportController extends BaseController {
COLOR_LIGHT, ParagraphAlignment.CENTER, 200, 100);
// 空行(撑出版面,与模板一致)
- for (int i = 0; i < 11; i++) {
+ // 行数按「签署表 4 行 × 680 twips(= 模板实测行高)刚好落在第 2 页、且第 2 页放不下
+ // 「一、巡检基本情况」」标定:签署表行高对齐模板后变高,故由 11 行减为 9 行(实测可用
+ // WPS 量出:末行仍在第 2 页、正文标题仍在第 3 页)。改这些参数前后务必量一下分页。
+ for (int i = 0; i < 9; i++) {
addPara(doc, "", null, SZ_BODY, false, false, null,
ParagraphAlignment.CENTER, 200, 100);
}
addPara(doc, "", null, SZ_BODY, false, false, null,
ParagraphAlignment.CENTER, 400, null);
- // 封面签署表:初审/校对/审核/巡检日期(4行×2列,宽4686 twips)
- XWPFTable table = addTable(doc, 4, 2, new int[]{1800, 2886}, 4686, true);
+ // 封面签署表:初审/校对/审核/巡检日期(4行×2列,宽4686 twips,居中,与模板一致)
+ XWPFTable table = addTable(doc, 4, COLS_COVER);
String[] labels = {"初审", "校对", "审核", "巡检日期"};
// TODO 归档时调整:初审/校对/审核 人员数据来源暂用占位符;巡检日期取巡视开始日期
String[] values = {"[填写]", "[填写]", "[填写]",
report.getInspectionStartTime() != null
? DateUtils.format(DateUtils.yyyyMMdd2, report.getInspectionStartTime()) : "[填写]"};
for (int i = 0; i < labels.length; i++) {
+ // 封面签署表不设最小行高:封面第二页的行数/空行是按版心精确排的,行高变化会把正文挤到封面页
setRow(table.getRow(i), null, true);
setRowJcCenter(table.getRow(i));
- // 标签:仿宋_GB2312 12pt 加粗 深蓝 居中
- styleCell(table.getRow(i).getCell(0), labels[i], FONT_BODY, SZ_BODY, true, false,
- COLOR_DARK, false, ParagraphAlignment.CENTER, 0, 0, 1800, null,
- 60, 150, 60, 120);
- // 值:仿宋_GB2312 12pt 灰色斜体 居中
- styleCell(table.getRow(i).getCell(1), values[i], FONT_BODY, SZ_BODY, false, true,
- COLOR_GRAY, false, ParagraphAlignment.CENTER, 0, 0, 2886, null,
- 60, 150, 60, 120);
+ // 标签:仿宋_GB2312 14pt 深蓝 居中,不加粗(与模板一致)
+ styleCell(table.getRow(i).getCell(0), singleLine(labels[i]), FONT_TABLE_HEAD, SZ_HEAD_FOURTH,
+ false, false, COLOR_DARK, false, ParagraphAlignment.CENTER,
+ COLS_COVER[0], null, LAYOUT_COVER);
+ // 值:仿宋_GB2312 14pt 灰色斜体 居中(模板为四号 14pt)
+ styleCell(table.getRow(i).getCell(1), singleLine(values[i]), FONT_BODY, SZ_HEAD_FOURTH,
+ false, true, COLOR_GRAY, false, ParagraphAlignment.CENTER,
+ COLS_COVER[1], null, LAYOUT_COVER);
}
}
/** 一、巡检基本情况 */
- private void buildSectionBasic(XWPFDocument doc, InspectionReport report) {
- XWPFParagraph h = addPara(doc, "一、巡检基本情况", FONT_HEAD, SZ_HEAD_THIRD, false, false,
- null, null, 160, 80);
- setKeepNext(h);
- setFirstLineIndentOneChar(h, SZ_HEAD_THIRD); // 标题首行缩进 1 字符
+ private void buildSectionBasic(XWPFDocument doc, InspectionReport report, BigInteger headingNumId) {
+ addSectionHeading(doc, "巡检基本情况", LVL_1, headingNumId, 160, 80);
- XWPFTable t = addTable(doc, 7, 4, new int[]{1827, 2655, 1920, 2659}, 9061, false);
+ XWPFTable t = addTable(doc, 7, COLS_BASIC);
List dataList = report.getReportDatalist();
// R0 表头(跨4列,深蓝底白字)
- setRow(t.getRow(0), ROW_H, false);
+ setRow(t.getRow(0), null, true);
mergeRowCells(t, 0, 0, 3);
- styleCell(t.getRow(0).getCell(0), "场站基本信息", FONT_BODY, SZ_HEAD_FOURTH, true, false,
- COLOR_WHITE, true, ParagraphAlignment.CENTER, 0, 0, 9061, 4,
- 0, CELL_MAR, 0, CELL_MAR);
+ headerCell(t.getRow(0).getCell(0), "场站基本信息", TABLE_W, 4);
// R1 电站名称 | 白杨坪 | 位置 | 四川省成都市
- setRow(t.getRow(1), ROW_H, false);
- bodyCell(t.getRow(1).getCell(0), "电站名称", 1827);
- bodyCell(t.getRow(1).getCell(1), val(report.getStationName()), 2655);
- bodyCell(t.getRow(1).getCell(2), "位置", 1920);
- bodyCell(t.getRow(1).getCell(3), val(report.getLocation()), 2659);
+ setRow(t.getRow(1), null, true);
+ bodyCell(t.getRow(1).getCell(0), "电站名称", COLS_BASIC[0]);
+ bodyCell(t.getRow(1).getCell(1), val(report.getStationName()), COLS_BASIC[1]);
+ bodyCell(t.getRow(1).getCell(2), "位置", COLS_BASIC[2]);
+ bodyCell(t.getRow(1).getCell(3), val(report.getLocation()), COLS_BASIC[3]);
// ==================== R2:巡视任务基本信息(新增表头) ====================
- setRow(t.getRow(2), ROW_H, false);
+ setRow(t.getRow(2), null, true);
mergeRowCells(t, 2, 0, 3);
- styleCell(t.getRow(2).getCell(0), "巡视任务基本信息", FONT_BODY, SZ_HEAD_FOURTH, true, false,
- COLOR_WHITE, true, ParagraphAlignment.CENTER, 0, 0, 9061, 4,
- 0, CELL_MAR, 0, CELL_MAR);
+ headerCell(t.getRow(2).getCell(0), "任务基本信息", TABLE_W, 4);
// R3 巡检任务 | xxx(跨3列)
- setRow(t.getRow(3), ROW_H, false);
- bodyCell(t.getRow(3).getCell(0), "巡检任务", 1827);
+ setRow(t.getRow(3), null, true);
+ bodyCell(t.getRow(3).getCell(0), "巡检任务", COLS_BASIC[0]);
mergeRowCells(t, 3, 1, 3);
- styleCell(t.getRow(3).getCell(1), val(report.getInspectionTaskName()), FONT_BODY, SZ_BODY,
- false, false, null, false, ParagraphAlignment.CENTER, 0, 0,
- 1827 + 2655 + 1920 + 2659 - 1827, 3, 0, CELL_MAR, 0, CELL_MAR);
+ bodyCell(t.getRow(3).getCell(1), val(report.getInspectionTaskName()),
+ sum(COLS_BASIC, 1, 3), 3);
// R4 巡检开始时间 | xx | 巡检结束时间 | xx
- setRow(t.getRow(4), ROW_H, false);
- bodyCell(t.getRow(4).getCell(0), "巡检开始时间", 1827);
- bodyCell(t.getRow(4).getCell(1), val(formatDateTime(report.getInspectionStartTime())), 2655);
- bodyCell(t.getRow(4).getCell(2), "巡检结束时间", 1920);
- bodyCell(t.getRow(4).getCell(3), val(formatDateTime(report.getInspectionEndTime())), 2659);
+ setRow(t.getRow(4), null, true);
+ bodyCell(t.getRow(4).getCell(0), "巡检开始时间", COLS_BASIC[0]);
+ bodyCell(t.getRow(4).getCell(1), val(formatDateTime(report.getInspectionStartTime())), COLS_BASIC[1]);
+ bodyCell(t.getRow(4).getCell(2), "巡检结束时间", COLS_BASIC[2]);
+ bodyCell(t.getRow(4).getCell(3), val(formatDateTime(report.getInspectionEndTime())), COLS_BASIC[3]);
// R5 巡检点位数量(个) | n | 巡检设备数量(个) | m
// TODO 归档时调整:数量口径(按点位名去重 / 按设备名去重)
- setRow(t.getRow(5), ROW_H, false);
- bodyCell(t.getRow(5).getCell(0), "巡检点位数量(个)", 1827);
+ setRow(t.getRow(5), null, true);
+ bodyCell(t.getRow(5).getCell(0), "巡检点位数量(个)", COLS_BASIC[0]);
bodyCell(t.getRow(5).getCell(1), String.valueOf(distinctCount(dataList,
- InspectionReportData::getPointName)), 2655);
- bodyCell(t.getRow(5).getCell(2), "巡检设备数量(个)", 1920);
+ InspectionReportData::getPointName)), COLS_BASIC[1]);
+ bodyCell(t.getRow(5).getCell(2), "巡检设备数量(个)", COLS_BASIC[2]);
bodyCell(t.getRow(5).getCell(3), String.valueOf(distinctCount(dataList,
- InspectionReportData::getEqName)), 2659);
+ InspectionReportData::getEqName)), COLS_BASIC[3]);
// R6 审核意见 | (跨3列,可空)
// TODO 归档时调整:审核意见来源(暂用 report.description,可改为 checkPerson 或空)
- setRow(t.getRow(6), ROW_H, false);
- bodyCell(t.getRow(6).getCell(0), "审核意见", 1827);
+ setRow(t.getRow(6), null, true);
+ bodyCell(t.getRow(6).getCell(0), "审核意见", COLS_BASIC[0]);
mergeRowCells(t, 6, 1, 3);
- styleCell(t.getRow(6).getCell(1), val(report.getDescription()), FONT_BODY, SZ_BODY,
- false, false, null, false, ParagraphAlignment.CENTER, 0, 0,
- 1827 + 2655 + 1920 + 2659 - 1827, 3, 0, CELL_MAR, 0, CELL_MAR);
+ bodyCell(t.getRow(6).getCell(1), val(report.getDescription()),
+ sum(COLS_BASIC, 1, 3), 3);
}
/** 二、巡检状态说明(静态文案,与模板一致) */
- private void buildSectionStatus(XWPFDocument doc) {
- XWPFParagraph h = addPara(doc, "二、巡检状态说明", FONT_HEAD, SZ_HEAD_THIRD, false, false,
- null, null, 160, 80);
- setKeepNext(h);
- setFirstLineIndentOneChar(h, SZ_HEAD_THIRD); // 标题首行缩进 1 字符
+ private void buildSectionStatus(XWPFDocument doc, BigInteger headingNumId) {
+ addSectionHeading(doc, "巡检状态说明", LVL_1, headingNumId, 160, 80);
- XWPFTable t = addTable(doc, 4, 3, new int[]{1831, 4115, 3114}, 9060, false);
+ XWPFTable t = addTable(doc, 4, COLS_STATUS);
String[][] rows = {
{"状态", "说明", "典型场景"},
{"缺陷", "巡检结果表明设备存在异常状态或潜在运行风险",
@@ -666,26 +889,23 @@ public class DonghuanReportExportController extends BaseController {
"表计读数正常、温度正常、设备外观正常"},
};
for (int i = 0; i < rows.length; i++) {
- setRow(t.getRow(i), ROW_H, false);
+ setRow(t.getRow(i), null, true);
if (i == 0) {
- headerCell(t.getRow(i).getCell(0), rows[i][0], 1831);
- headerCell(t.getRow(i).getCell(1), rows[i][1], 4115);
- headerCell(t.getRow(i).getCell(2), rows[i][2], 3114);
+ headerCell(t.getRow(i).getCell(0), rows[i][0], COLS_STATUS[0]);
+ headerCell(t.getRow(i).getCell(1), rows[i][1], COLS_STATUS[1]);
+ headerCell(t.getRow(i).getCell(2), rows[i][2], COLS_STATUS[2]);
} else {
- bodyCell(t.getRow(i).getCell(0), rows[i][0], 1831, true);
- bodyCell(t.getRow(i).getCell(1), rows[i][1], 4115, false);
- bodyCell(t.getRow(i).getCell(2), rows[i][2], 3114, false);
+ bodyCell(t.getRow(i).getCell(0), rows[i][0], COLS_STATUS[0]);
+ bodyCell(t.getRow(i).getCell(1), rows[i][1], COLS_STATUS[1]);
+ bodyCell(t.getRow(i).getCell(2), rows[i][2], COLS_STATUS[2]);
}
}
}
/** 三、巡检结果概览:(一)巡检统计 + (二)缺陷汇总表(含图片信息列) */
private void buildSectionOverview(XWPFDocument doc, InspectionReport report,
- Map tempFileMap) {
- XWPFParagraph h = addPara(doc, "三、巡检结果概览", FONT_HEAD, SZ_HEAD_THIRD, false, false,
- null, null, 160, 80);
- setKeepNext(h);
- setFirstLineIndentOneChar(h, SZ_HEAD_THIRD); // 标题首行缩进 1 字符
+ Map tempFileMap, BigInteger headingNumId) {
+ addSectionHeading(doc, "巡检结果概览", LVL_1, headingNumId, 160, 80);
List dataList = report.getReportDatalist() != null
? report.getReportDatalist() : Collections.emptyList();
@@ -694,14 +914,11 @@ public class DonghuanReportExportController extends BaseController {
String statusNormal = messageUtils.get("正常");
// (一)巡检统计
- XWPFParagraph h1 = addPara(doc, "(一)巡检统计", FONT_BODY, SZ_HEAD_THIRD, false, false,
- null, null, 80, 40);
- setKeepNext(h1);
- setFirstLineIndentOneChar(h1, SZ_HEAD_THIRD); // 标题首行缩进 1 字符
- XWPFTable statTable = addTable(doc, 5, 2, new int[]{4296, 4713}, 9008, true);
- setRow(statTable.getRow(0), ROW_H, false);
- headerCell(statTable.getRow(0).getCell(0), "巡检状态", 4296);
- headerCell(statTable.getRow(0).getCell(1), "数量(个)", 4713);
+ addSectionHeading(doc, "巡检统计", LVL_2, headingNumId, 80, 40);
+ XWPFTable statTable = addTable(doc, 5, COLS_STAT);
+ setRow(statTable.getRow(0), null, true);
+ headerCell(statTable.getRow(0).getCell(0), "巡检状态", COLS_STAT[0]);
+ headerCell(statTable.getRow(0).getCell(1), "数量(个)", COLS_STAT[1]);
String[][] statRows = {
{statusDefect, String.valueOf(countByStatus(dataList, statusDefect))},
{statusAbnormal, String.valueOf(countByStatus(dataList, statusAbnormal))},
@@ -709,70 +926,59 @@ public class DonghuanReportExportController extends BaseController {
{"合计", String.valueOf(dataList.size())},
};
for (int i = 0; i < statRows.length; i++) {
- setRow(statTable.getRow(i + 1), ROW_H, false);
- bodyCell(statTable.getRow(i + 1).getCell(0), statRows[i][0], 4296, true);
- bodyCell(statTable.getRow(i + 1).getCell(1), statRows[i][1], 4713, true);
+ setRow(statTable.getRow(i + 1), null, true);
+ bodyCell(statTable.getRow(i + 1).getCell(0), statRows[i][0], COLS_STAT[0]);
+ bodyCell(statTable.getRow(i + 1).getCell(1), statRows[i][1], COLS_STAT[1]);
}
// (二)缺陷汇总表
- XWPFParagraph h2 = addPara(doc, "(二)缺陷汇总表", FONT_BODY, SZ_HEAD_THIRD, false, false,
- null, null, 80, 40);
- setKeepNext(h2);
- setFirstLineIndentOneChar(h2, SZ_HEAD_THIRD); // 标题首行缩进 1 字符
+ addSectionHeading(doc, "缺陷汇总表", LVL_2, headingNumId, 80, 40);
List defectList = dataList.stream()
.filter(d -> statusDefect.equals(d.getPointStatus()))
.collect(Collectors.toList());
// 按批注要求:异常汇总(缺陷汇总)列表增加「图片信息」列,展示异常点位图片
- int[] summaryWidths = {650, 1088, 1115, 1128, 1100, 1549, 896, 658, 850};
- int summaryTotalWidth = Arrays.stream(summaryWidths).sum();
+ int summaryTotalWidth = sum(COLS_SUMMARY, 0, COLS_SUMMARY.length - 1);
// 无缺陷数据时保留一行(合并整行输出文字说明),避免表格空白
int summaryRows = 1 + Math.max(defectList.size(), 1);
- XWPFTable summaryTable = addTable(doc, summaryRows, 9, summaryWidths, summaryTotalWidth, false);
+ XWPFTable summaryTable = addTable(doc, summaryRows, COLS_SUMMARY);
String[] summaryHeaders = {"序号", "区域名称", "间隔名称", "设备名称", "部件名称",
- "巡检点位名称", "巡检", "状态", "图片信息"};
- setRow(summaryTable.getRow(0), ROW_H, true);
+ "巡检点位名称", "巡检结果", "状态", "图片信息"};
+ setRow(summaryTable.getRow(0), null, true);
for (int i = 0; i < summaryHeaders.length; i++) {
- headerCell(summaryTable.getRow(0).getCell(i), summaryHeaders[i], summaryWidths[i]);
+ headerCell(summaryTable.getRow(0).getCell(i), summaryHeaders[i], COLS_SUMMARY[i]);
}
- // 「巡检 / 结果」表头换行
- XWPFParagraph headP = summaryTable.getRow(0).getCell(6).getParagraphs().get(0);
- setRunFont(headP.createRun(), "结果", FONT_BODY, SZ_HEAD_FOURTH, true, false, COLOR_WHITE, true);
int num = 1;
// 图片信息列缩略图尺寸:宽 = 单元格内容宽(列宽 - 左右边距),高按缺陷图比例等比缩放
- long summaryImgW = (summaryWidths[8] - 2L * CELL_MAR) * 635L;
+ long summaryImgW = contentWidth(COLS_SUMMARY[8]) * EMU_PER_TWIP;
long summaryImgH = summaryImgW * IMG_DEFECT_H / IMG_DEFECT_W;
for (int ri = 0; ri < defectList.size(); ri++) {
InspectionReportData d = defectList.get(ri);
- setRow(summaryTable.getRow(ri + 1), ROW_H, true);
+ setRow(summaryTable.getRow(ri + 1), null, true);
String[] vals = {String.valueOf(num++), val(d.getArea()), val(d.getTimeInterval()),
val(d.getEqName()), val(d.getParts()), val(d.getPointName()),
val(d.getInspectionResults()), val(d.getPointStatus())};
for (int ci = 0; ci < vals.length; ci++) {
- bodyCell(summaryTable.getRow(ri + 1).getCell(ci), vals[ci],
- summaryWidths[ci], false, 120, 120);
+ bodyCell(summaryTable.getRow(ri + 1).getCell(ci), vals[ci], COLS_SUMMARY[ci]);
}
// 图片信息:该异常点位图片(优先缺陷图,其次原图;均无则横杠占位)
imageCell(summaryTable.getRow(ri + 1).getCell(8),
- defectSummaryImagePath(d, tempFileMap), summaryImgW, summaryImgH, summaryWidths[8]);
+ defectSummaryImagePath(d, tempFileMap), summaryImgW, summaryImgH, COLS_SUMMARY[8]);
}
if (defectList.isEmpty()) {
// 无缺陷数据时:整行合并输出文字说明(原占位横杠改为「本次巡检未发现缺陷项」)
- setRow(summaryTable.getRow(1), ROW_H, true);
+ setRow(summaryTable.getRow(1), null, true);
mergeRowCells(summaryTable, 1, 0, 8);
styleCell(summaryTable.getRow(1).getCell(0),
- messageUtils.get("本次巡检未发现缺陷项"), FONT_BODY, SZ_BODY,
- false, false, null, false, ParagraphAlignment.CENTER, 120, 120,
- summaryTotalWidth, 9, 0, CELL_MAR, 0, CELL_MAR);
+ singleLine(messageUtils.get("本次巡检未发现缺陷项")), FONT_BODY, SZ_BODY,
+ false, false, null, false, ParagraphAlignment.CENTER,
+ summaryTotalWidth, 9, LAYOUT_BODY);
}
}
/** 四、巡检详情:每个点位一张 8 行 × 4 列详情表 */
private void buildSectionDetail(XWPFDocument doc, InspectionReport report,
- Map tempFileMap) {
- XWPFParagraph h = addPara(doc, "四、巡检详情", FONT_HEAD, SZ_HEAD_THIRD, false, false,
- null, null, 160, 80);
- setKeepNext(h);
- setFirstLineIndentOneChar(h, SZ_HEAD_THIRD); // 标题首行缩进 1 字符
+ Map tempFileMap, BigInteger headingNumId) {
+ addSectionHeading(doc, "巡检详情", LVL_1, headingNumId, 160, 80);
List dataList = report.getReportDatalist() != null
? report.getReportDatalist().stream().filter(item -> messageUtils.get("缺陷").equals(item.getPointStatus())).collect(Collectors.toList()) : Collections.emptyList();
@@ -784,8 +990,12 @@ public class DonghuanReportExportController extends BaseController {
return;
}
for (InspectionReportData d : dataList) {
- XWPFTable t = addTable(doc, 8, 4, new int[]{1707, 2891, 1665, 2785}, 9048, false);
- int[] grid = {1707, 2891, 1665, 2785};
+ XWPFTable t = addTable(doc, 8, COLS_DETAIL);
+
+ // 图片按「基准图/缺陷图」列的内容宽等比缩放(两张图取同一宽度,视觉一致且不撑破单元格)
+ long imgW = contentWidth(Math.min(COLS_DETAIL[1], COLS_DETAIL[3])) * EMU_PER_TWIP;
+ long patrolH = imgW * IMG_PATROL_H / IMG_PATROL_W;
+ long defectH = imgW * IMG_DEFECT_H / IMG_DEFECT_W;
String[][] pairs = {
{"巡检点位名称", val(d.getPointName()), "巡检时间", val(formatDateTime(d.getAcquisitionTime()))},
@@ -795,33 +1005,31 @@ public class DonghuanReportExportController extends BaseController {
{"巡检读数", val(d.getInspectionValue()), "巡检结果", val(d.getInspectionResults())},
};
for (int ri = 0; ri < pairs.length; ri++) {
- setRow(t.getRow(ri), ROW_H, false);
- bodyCell(t.getRow(ri).getCell(0), pairs[ri][0], grid[0], false, 120, 120);
- bodyCell(t.getRow(ri).getCell(1), pairs[ri][1], grid[1], false, 120, 120);
- bodyCell(t.getRow(ri).getCell(2), pairs[ri][2], grid[2], false, 120, 120);
- bodyCell(t.getRow(ri).getCell(3), pairs[ri][3], grid[3], false, 120, 120);
+ setRow(t.getRow(ri), null, true);
+ // 奇数/偶数列为栏头(title),取与缺陷汇总表表头相同的样式,便于与取值区分
+ headerCell(t.getRow(ri).getCell(0), pairs[ri][0], COLS_DETAIL[0]);
+ bodyCell(t.getRow(ri).getCell(1), pairs[ri][1], COLS_DETAIL[1]);
+ headerCell(t.getRow(ri).getCell(2), pairs[ri][2], COLS_DETAIL[2]);
+ bodyCell(t.getRow(ri).getCell(3), pairs[ri][3], COLS_DETAIL[3]);
}
// R5 巡检状态 | 状态(跨3列)
- setRow(t.getRow(5), ROW_H, false);
- bodyCell(t.getRow(5).getCell(0), "巡检状态", grid[0], false, 120, 120);
+ setRow(t.getRow(5), null, true);
+ headerCell(t.getRow(5).getCell(0), "巡检状态", COLS_DETAIL[0]);
mergeRowCells(t, 5, 1, 3);
- styleCell(t.getRow(5).getCell(1), val(d.getPointStatus()), FONT_BODY, SZ_BODY,
- false, false, null, false, ParagraphAlignment.CENTER, 120, 120,
- grid[1] + grid[2] + grid[3], 3, 0, CELL_MAR, 0, CELL_MAR);
+ bodyCell(t.getRow(5).getCell(1), val(d.getPointStatus()),
+ sum(COLS_DETAIL, 1, 3), 3);
// R6 巡检原图 | 图片文件名(跨3列)
String imgName = firstImageName(d);
- setRow(t.getRow(6), ROW_H, false);
- bodyCell(t.getRow(6).getCell(0), "巡检原图", grid[0], false, 120, 120);
+ setRow(t.getRow(6), null, true);
+ headerCell(t.getRow(6).getCell(0), "巡检原图", COLS_DETAIL[0]);
mergeRowCells(t, 6, 1, 3);
- styleCell(t.getRow(6).getCell(1), val(imgName), FONT_BODY, SZ_BODY,
- false, false, null, false, ParagraphAlignment.CENTER, 120, 120,
- grid[1] + grid[2] + grid[3], 3, 0, CELL_MAR, 0, CELL_MAR);
+ bodyCell(t.getRow(6).getCell(1), val(imgName), sum(COLS_DETAIL, 1, 3), 3);
// R7 基准图 | 巡检原图照片 | 缺陷图 | 缺陷图照片
// 图片列表为空或路径缺失时置 null,由 imageCell 输出横杠占位,避免空白/空指针
- setRow(t.getRow(7), ROW_H, false);
+ setRow(t.getRow(7), null, true);
Path patrolPath = null;
Path defectPath = null;
if (d.getReportImgList() != null && !d.getReportImgList().isEmpty()) {
@@ -829,12 +1037,10 @@ public class DonghuanReportExportController extends BaseController {
patrolPath = tempFileMap.get(inspectionReportImg.getImageNormalUrlPath());
defectPath = tempFileMap.get(inspectionReportImg.getImg());
}
- bodyCell(t.getRow(7).getCell(0), "基准图", grid[0], false, 120, 120);
- imageCell(t.getRow(7).getCell(1), patrolPath,
- IMG_PATROL_W, IMG_PATROL_H, grid[1]);
- bodyCell(t.getRow(7).getCell(2), "缺陷图", grid[2], false, 120, 120);
- imageCell(t.getRow(7).getCell(3), defectPath,
- IMG_DEFECT_W, IMG_DEFECT_H, grid[3]);
+ headerCell(t.getRow(7).getCell(0), "基准图", COLS_DETAIL[0]);
+ imageCell(t.getRow(7).getCell(1), patrolPath, imgW, patrolH, COLS_DETAIL[1]);
+ headerCell(t.getRow(7).getCell(2), "缺陷图", COLS_DETAIL[2]);
+ imageCell(t.getRow(7).getCell(3), defectPath, imgW, defectH, COLS_DETAIL[3]);
// 表间间距
addPara(doc, "", null, SZ_BODY, false, false, null, null, 120, 120);
@@ -868,17 +1074,14 @@ public class DonghuanReportExportController extends BaseController {
spacing.setLineRule(STLineSpacingRule.AUTO);
if (text != null && !text.isEmpty()) {
XWPFRun run = p.createRun();
- setRunFont(run, text, font, halfPoints, bold, italic, color, false);
+ setRunFont(run, text, font, halfPoints, bold, italic, color);
}
return p;
}
/** 设置 run 字体(含中文 eastAsia)、字号、加粗、斜体、颜色 */
private void setRunFont(XWPFRun run, String text, String font, int halfPoints,
- boolean bold, boolean italic, String color, boolean lineBreak) {
- if (lineBreak) {
- run.addBreak(); // 换行符在前,实现「巡检⏎结果」这类两行表头
- }
+ boolean bold, boolean italic, String color) {
if (text != null) {
run.setText(text);
}
@@ -906,10 +1109,20 @@ public class DonghuanReportExportController extends BaseController {
}
}
- /** 建表:设置 tblW / 边框 / autofit / 网格列宽 */
- private XWPFTable addTable(XWPFDocument doc, int rows, int cols, int[] gridWidths,
- int tblWidth, boolean center) {
- XWPFTable table = doc.createTable(rows, cols);
+ /**
+ * 建表:设置 tblW / 边框 / 固定列宽 / 网格列宽。
+ *
+ * tblW 由列宽之和推导(不再单独传参),tblLayout 固定 FIXED:列宽完全按 tblGrid 渲染,
+ * 不同渲染器(Word / WPS / LibreOffice)结果一致,进而保证「通篇表格宽度统一」。
+ */
+ private XWPFTable addTable(XWPFDocument doc, int rows, int[] gridWidths) {
+ int tblWidth = sum(gridWidths, 0, gridWidths.length - 1);
+ // 封面签署表(COLS_COVER)是签名栏,宽度与正文表格不同;其余表格必须等于版心宽 TABLE_W
+ if (tblWidth != TABLE_W && gridWidths != COLS_COVER) {
+ logger.warn("[TABLE] 表格宽度 {} 与版心宽度 {} 不一致,列宽={}",
+ tblWidth, TABLE_W, Arrays.toString(gridWidths));
+ }
+ XWPFTable table = doc.createTable(rows, gridWidths.length);
CTTbl tbl = table.getCTTbl();
CTTblPr tblPr = tbl.getTblPr();
if (tblPr == null) {
@@ -919,16 +1132,15 @@ public class DonghuanReportExportController extends BaseController {
tblW.setW(BigInteger.valueOf(tblWidth));
tblW.setType(STTblWidth.DXA);
- if (center) {
- CTJc jc = tblPr.isSetJc() ? tblPr.getJc() : tblPr.addNewJc();
- jc.setVal(STJc.CENTER);
- }
+ // 表格水平居中(宽度等于版心宽时与左对齐视觉效果一致,封面签署表居中)
+ CTJc jc = tblPr.isSetJc() ? tblPr.getJc() : tblPr.addNewJc();
+ jc.setVal(STJc.CENTER);
CTTblBorders borders = tblPr.isSetTblBorders() ? tblPr.getTblBorders() : tblPr.addNewTblBorders();
setBorders(borders);
CTTblLayoutType layout = tblPr.isSetTblLayout() ? tblPr.getTblLayout() : tblPr.addNewTblLayout();
- layout.setType(STTblLayoutType.AUTOFIT);
+ layout.setType(STTblLayoutType.FIXED);
CTTblCellMar cellMar = tblPr.isSetTblCellMar() ? tblPr.getTblCellMar() : tblPr.addNewTblCellMar();
setCellMar(cellMar, 0, CELL_MAR, 0, CELL_MAR);
@@ -1005,7 +1217,11 @@ public class DonghuanReportExportController extends BaseController {
r.setType(STTblWidth.DXA);
}
- /** 行高(atLeast)+ 可选 cantSplit */
+ /**
+ * 行高(可空 = 自动,与模板一致;行高由单元格行距 + 上下边距决定)+ 可选 cantSplit。
+ * 注意:写 atLeast 最小值时 WPS 会把它当作「内容高度」,在其之上再加单元格上下边距,
+ * 单行数据行会比模板高约 7 磅,故正文表格一律不设最小行高。
+ */
private void setRow(XWPFTableRow row, Integer height, boolean cantSplit) {
// 旧版 poi-ooxml-schemas 中 w:tr 的类型是 CTRow(非 CTr),
// 且 trPr 内元素为数组式访问器(sizeOfXxxArray/getXxxArray/addNewXxx)
@@ -1042,37 +1258,51 @@ public class DonghuanReportExportController extends BaseController {
}
}
- /** 表头单元格:深蓝底 + 白字 + 加粗 + 居中(仿宋_GB2312 四号 14pt,与模板表头一致) */
- private void headerCell(XWPFTableCell cell, String text, int tcw) {
- styleCell(cell, text, FONT_BODY, SZ_HEAD_FOURTH, true, false, COLOR_WHITE, true,
- ParagraphAlignment.CENTER, 0, 0, tcw, null, 0, CELL_MAR, 0, CELL_MAR);
+ // ---------------------------------------------------------------------------
+ // 单元格(表头 / 栏头 / 数据 / 图片)
+ // ---------------------------------------------------------------------------
+
+ /**
+ * 表头 / 栏头单元格:深蓝底 + 白字 + 加粗 + 居中,文字按列宽自动均衡换行
+ * (四号 14pt;换行见 {@link #wrapLines})。
+ *
+ * 除列标题行外,巡检详情表内「标注取值用的栏头」也走这里(巡检点位名称 / 巡检时间 /
+ * 区域名称 / 基准图 / 缺陷图 等),这样标题与取值样式明显区分。
+ *
+ * @param colWidth 该单元格所占列宽合计(twips,跨列时为各列之和)
+ */
+ private void headerCell(XWPFTableCell cell, String text, int colWidth, Integer gridSpan) {
+ styleCell(cell, wrapLines(text, contentWidth(colWidth), SZ_HEAD_FOURTH, true),
+ FONT_TABLE_HEAD, SZ_HEAD_FOURTH, true, false, COLOR_WHITE, true,
+ ParagraphAlignment.CENTER, colWidth, gridSpan, LAYOUT_HEAD);
}
- /** 正文单元格:仿宋_GB2312 小四 12pt 居中、单倍行距(表格文字居中,与模板一致;center 参数保留兼容调用方) */
- private void bodyCell(XWPFTableCell cell, String text, int tcw, boolean center,
- int before, int after) {
- styleCell(cell, text, FONT_BODY, SZ_BODY, false, false, null, false,
- ParagraphAlignment.CENTER,
- before, after, tcw, null, 0, CELL_MAR, 0, CELL_MAR);
+ /**
+ * 数据单元格:仿宋_GB2312 小四 12pt、居中、单倍行距(表格文字统一居中,与模板一致)
+ */
+ private void bodyCell(XWPFTableCell cell, String text, int colWidth, Integer gridSpan) {
+ styleCell(cell, wrapLines(text, contentWidth(colWidth), SZ_BODY, false),
+ FONT_BODY, SZ_BODY, false, false, null, false,
+ ParagraphAlignment.CENTER, colWidth, gridSpan, LAYOUT_BODY);
}
- private void bodyCell(XWPFTableCell cell, String text, int tcw, boolean center) {
- bodyCell(cell, text, tcw, center, 0, 0);
+ private void headerCell(XWPFTableCell cell, String text, int colWidth) {
+ headerCell(cell, text, colWidth, null);
}
- private void bodyCell(XWPFTableCell cell, String text, int tcw) {
- bodyCell(cell, text, tcw, false, 0, 0);
+ private void bodyCell(XWPFTableCell cell, String text, int colWidth) {
+ bodyCell(cell, text, colWidth, null);
}
- /** 图片单元格:细边框 + 垂直居中 + 图片居中(保持模板尺寸);无图时输出横杠占位 */
- private void imageCell(XWPFTableCell cell, Path imagePath, long w, long h, int tcw) {
- styleCell(cell, "", FONT_BODY, SZ_BODY, false, false, null, false,
- ParagraphAlignment.CENTER, 120, 120, tcw, null, 0, CELL_MAR, 0, CELL_MAR);
+ /** 图片单元格:细边框 + 垂直居中 + 图片居中;无图时输出横杠占位 */
+ private void imageCell(XWPFTableCell cell, Path imagePath, long w, long h, int colWidth) {
+ styleCell(cell, singleLine(""), FONT_BODY, SZ_BODY, false, false, null, false,
+ ParagraphAlignment.CENTER, colWidth, null, LAYOUT_BODY);
XWPFParagraph p = cell.getParagraphs().get(0);
if (imagePath == null) {
// 无图片时以横杠占位,避免空白单元格
XWPFRun dashRun = p.createRun();
- setRunFont(dashRun, "--", FONT_BODY, SZ_BODY, false, false, null, false);
+ setRunFont(dashRun, "--", FONT_BODY, SZ_BODY, false, false, null);
return;
}
XWPFRun run = p.createRun();
@@ -1084,21 +1314,22 @@ public class DonghuanReportExportController extends BaseController {
}
/**
- * 通用单元格样式:B4C6E7 细边框(sz=0)、垂直居中、可选深蓝底、
- * 列宽、gridSpan、单元格边距
+ * 通用单元格样式:B4C6E7 细边框(sz=0)、垂直居中、可选深蓝底、列宽、gridSpan,
+ * 内边距与段落行高取自 {@link CellLayout}(表头 / 正文 / 封面签署表,均按模板实测值)。
+ *
+ * 文字按传入的行列表逐行输出为「一个段落一行」(与模板表头一致):Word 只在段落内自动换行,
+ * 显式分行可以精确控制表头等短文本的断行位置,避免出现「一个汉字占一行」等不美观的换行。
+ * 段落统一居中、段前段后为 0、首行/左右缩进为 0(模板单元格段落同样清零缩进)。
*/
- private void styleCell(XWPFTableCell cell, String text, String font, int halfPoints,
+ private void styleCell(XWPFTableCell cell, List lines, String font, int halfPoints,
boolean bold, boolean italic, String color, boolean shade,
- ParagraphAlignment align, int before, int after,
- Integer tcw, Integer gridSpan,
- int marTop, int marLeft, int marBottom, int marRight) {
+ ParagraphAlignment align, int colWidth, Integer gridSpan,
+ CellLayout layout) {
CTTcPr tcPr = cell.getCTTc().isSetTcPr() ? cell.getCTTc().getTcPr() : cell.getCTTc().addNewTcPr();
- if (tcw != null) {
- CTTblWidth w = tcPr.isSetTcW() ? tcPr.getTcW() : tcPr.addNewTcW();
- w.setW(BigInteger.valueOf(tcw));
- w.setType(STTblWidth.DXA);
- }
+ CTTblWidth w = tcPr.isSetTcW() ? tcPr.getTcW() : tcPr.addNewTcW();
+ w.setW(BigInteger.valueOf(colWidth));
+ w.setType(STTblWidth.DXA);
if (gridSpan != null && gridSpan > 1) {
CTDecimalNumber gs = tcPr.isSetGridSpan() ? tcPr.getGridSpan() : tcPr.addNewGridSpan();
gs.setVal(BigInteger.valueOf(gridSpan));
@@ -1112,31 +1343,287 @@ public class DonghuanReportExportController extends BaseController {
shd.setFill(COLOR_DARK);
}
CTTcMar tcMar = tcPr.isSetTcMar() ? tcPr.getTcMar() : tcPr.addNewTcMar();
- setCellMar(tcMar, marTop, marLeft, marBottom, marRight);
+ setCellMar(tcMar, layout.marTop, layout.marLeft, layout.marBottom, layout.marRight);
CTVerticalJc vAlign = tcPr.isSetVAlign() ? tcPr.getVAlign() : tcPr.addNewVAlign();
vAlign.setVal(STVerticalJc.CENTER);
- // 段落:清空默认段落,写入第一个段落
+ // 段落:保留第一段(复用 POI 已登记的段落对象),删除多余段落后按行重建
List ctPList = cell.getCTTc().getPList();
for (int i = ctPList.size() - 1; i > 0; i--) {
cell.getCTTc().removeP(i);
}
- XWPFParagraph p = cell.getParagraphs().get(0);
- for (int i = p.getRuns().size() - 1; i >= 0; i--) {
- p.removeRun(i);
- }
- p.setAlignment(align);
- p.setSpacingBefore(before);
- p.setSpacingAfter(after);
- // 表格内段落与模板一致:单倍行距(w:line=240 auto),不受 Normal 样式 1.15 倍影响
- CTPPr cellPPr = p.getCTP().isSetPPr() ? p.getCTP().getPPr() : p.getCTP().addNewPPr();
- CTSpacing cellSpacing = cellPPr.isSetSpacing() ? cellPPr.getSpacing() : cellPPr.addNewSpacing();
- cellSpacing.setLine(BigInteger.valueOf(240));
- cellSpacing.setLineRule(STLineSpacingRule.AUTO);
- if (text != null && !text.isEmpty()) {
- XWPFRun run = p.createRun();
- setRunFont(run, text, font, halfPoints, bold, italic, color, false);
+ int lineCount = Math.max(1, lines == null ? 1 : lines.size());
+ for (int i = 0; i < lineCount; i++) {
+ XWPFParagraph p = (i == 0) ? cell.getParagraphs().get(0) : cell.addParagraph();
+ for (int r = p.getRuns().size() - 1; r >= 0; r--) {
+ p.removeRun(r);
+ }
+ p.setAlignment(align);
+ p.setSpacingBefore(0);
+ // 多行单元格(表头折行)中,行与行之间留 lineGapTw,使行首距与模板一致,最后一行不留
+ p.setSpacingAfter(i < lineCount - 1 ? layout.lineGapTw : 0);
+ // 缩进清零:与模板单元格段落一致(模板显式写 left/right/firstLine=0)
+ p.setIndentationLeft(0);
+ p.setIndentationRight(0);
+ p.setIndentationFirstLine(0);
+ // 段落行高按 CellLayout(表头 362 贴合文字 / 正文 312 / 封面 560 固定值),与模板实测一致
+ CTPPr cellPPr = p.getCTP().isSetPPr() ? p.getCTP().getPPr() : p.getCTP().addNewPPr();
+ CTSpacing cellSpacing = cellPPr.isSetSpacing() ? cellPPr.getSpacing() : cellPPr.addNewSpacing();
+ cellSpacing.setLine(BigInteger.valueOf(layout.lineTw));
+ cellSpacing.setLineRule(layout.lineRule);
+ String text = (lines == null || i >= lines.size()) ? "" : lines.get(i);
+ if (text != null && !text.isEmpty()) {
+ XWPFRun run = p.createRun();
+ setRunFont(run, text, font, halfPoints, bold, italic, color);
+ }
+ }
+ }
+
+ // ---------------------------------------------------------------------------
+ // 换行计算(表头均衡换行 / 正文避免孤字)
+ // ---------------------------------------------------------------------------
+
+ /** 单行文本包装(不参与自动换行计算) */
+ private static List singleLine(String text) {
+ return Collections.singletonList(text == null ? "" : text);
+ }
+
+ /** 单元格可用文字宽度(twips)= 列宽 - 左右内边距 */
+ private static int contentWidth(int colWidth) {
+ return Math.max(1, colWidth - 2 * CELL_MAR);
+ }
+
+ /** 列宽合计(含首尾) */
+ private static int sum(int[] widths, int from, int to) {
+ int total = 0;
+ for (int i = from; i <= to; i++) {
+ total += widths[i];
+ }
+ return total;
+ }
+
+ /**
+ * 计算单元格文字的显式换行行列表。
+ *
+ * 规则:
+ *
+ * - 按列宽贪心换行(全角字宽 = 字号,半角按一半),遵守避头尾与「不拆半角字串」
+ * (如 35kV、1#1、时间戳不会被拆开);找不到合法断点时交回 Word 自动换行;
+ * - {@code balanced=true}(表头):两行时按语义拆分(「(个)」等括号单元整体留到末行、
+ * 以「名称/结果/状态…」等类别词结尾时按 4+2 或 2+2 拆);
+ * - 末行只剩 1 个字(孤字)时把断点前移一位,避免出现「一整行只有一个字」的换行。
+ *
+ *
+ * @return 行文本列表;只有一行时返回 {@code ["原文"]},交由 Word 自动换行
+ */
+ private static List wrapLines(String text, int availTw, int halfPoints, boolean balanced) {
+ if (text == null || text.isEmpty()) {
+ return singleLine(text);
+ }
+ int em = halfPoints * 10; // 一个全角字的宽度(twips)
+ List natural = greedyBreaks(text, availTw, em);
+ if (natural.isEmpty()) {
+ return singleLine(text); // 一行放得下,或没有合法断点
+ }
+ List breaks = natural;
+ int pretty = -1;
+ if (balanced && natural.size() == 1 && isCjkText(text)) {
+ // 表头两行且为纯中文:按语义(括号单元 / 类别词)均衡拆分
+ pretty = prettySplit(text, availTw, em);
+ }
+ if (pretty > 0 && pretty < text.length() && isSafeBreak(text, pretty)) {
+ breaks = Collections.singletonList(pretty);
+ } else {
+ int lastBreak = natural.get(natural.size() - 1);
+ if (text.length() - lastBreak == 1) {
+ // 末行只剩 1 个字(孤字)→ 断点前移一位,且保证前一行仍有至少 2 个字
+ int prevBreak = natural.size() >= 2 ? natural.get(natural.size() - 2) : 0;
+ int candidate = lastBreak - 1;
+ if (candidate - prevBreak >= 2 && isSafeBreak(text, candidate)) {
+ breaks = new ArrayList<>(natural);
+ breaks.set(breaks.size() - 1, candidate);
+ }
+ }
+ }
+ List lines = new ArrayList<>(breaks.size() + 1);
+ int start = 0;
+ for (int brk : breaks) {
+ lines.add(text.substring(start, brk));
+ start = brk;
+ }
+ lines.add(text.substring(start));
+ return lines;
+ }
+
+ /**
+ * 贪心换行:返回每个新行的起始下标(不含第一行的 0);
+ * 无合法断点(例如整段是半角长串)或一行放得下时返回空列表,表示交给 Word 自动换行。
+ */
+ private static List greedyBreaks(String text, int availTw, int em) {
+ List breaks = new ArrayList<>();
+ int len = text.length();
+ int start = 0;
+ while (start < len) {
+ int width = 0;
+ int i = start;
+ int fitted = start; // 本行按宽度放得下的截止下标
+ while (i < len) {
+ int cw = charWidth(text.charAt(i), em);
+ if (width + cw > availTw && i > start) {
+ break;
+ }
+ width += cw;
+ i++;
+ fitted = i;
+ }
+ if (i >= len) {
+ break; // 剩余内容一行放得下
+ }
+ int brk = fitted;
+ // 避头尾:行首不能是收尾标点 → 标点留在上一行(与 Word 的避头尾一致,允许该行略微超宽)
+ while (brk < len && NO_LINE_START.indexOf(text.charAt(brk)) >= 0) {
+ brk++;
+ }
+ if (brk >= len) {
+ break; // 剩余内容(含末尾标点)都留在本行
+ }
+ // 避头尾:开括号不留行尾 → 移到下一行;不合法(如拆开半角字串)则继续向前找
+ while (brk > start && !isSafeBreak(text, brk)) {
+ brk--;
+ }
+ if (brk <= start) {
+ return Collections.emptyList(); // 本行内没有合法断点:整体交给 Word 自动换行
+ }
+ breaks.add(brk);
+ start = brk;
+ }
+ return breaks;
+ }
+
+ /**
+ * 断点是否合法:断点两侧的字符合并成一行不会破坏排版 ——
+ * 行首不能是收尾标点、行尾不能是开括号、不能把半角字串(英文/数字)拆开。
+ */
+ private static boolean isSafeBreak(String text, int pos) {
+ if (pos <= 0 || pos >= text.length()) {
+ return false;
+ }
+ char prev = text.charAt(pos - 1);
+ char next = text.charAt(pos);
+ if (NO_LINE_START.indexOf(next) >= 0 || NO_LINE_END.indexOf(prev) >= 0) {
+ return false;
+ }
+ return isFullWidth(prev) || isFullWidth(next);
+ }
+
+ /**
+ * 两行表头的优雅断点:优先按语义拆分,否则按字数均衡拆分。
+ *
+ * - 「巡检点位数量(个)」→「巡检点位数量」/「(个)」;首行放不下时退为
+ * 「巡检点位」/「数量(个)」(括号单元连同其前的类别词一起后移);
+ * - 「巡检点位名称」→「巡检点位」/「名称」;「识别类型」→「识别」/「类型」:类别词整体留到末行;
+ * - 其余按字数取中间位置(前少后多,视觉更稳)。
+ *
+ *
+ * @return 断点下标;返回 -1 表示没有既符合语义又放得下的拆法(调用方退回按宽度贪心换行)
+ */
+ private static int prettySplit(String text, int availTw, int em) {
+ int len = text.length();
+ List candidates = new ArrayList<>();
+ int bracket = trailingBracketStart(text);
+ if (bracket >= 2) {
+ candidates.add(bracket); // 「巡检点位数量」/「(个)」
+ for (String tail : TAIL_WORDS) {
+ int p = bracket - tail.length(); // 「巡检点位」/「数量(个)」
+ if (p >= 2 && text.startsWith(tail, p)) {
+ candidates.add(p);
+ }
+ }
+ }
+ if (len >= 4) {
+ for (String tail : TAIL_WORDS) { // 「区域」/「名称」
+ if (text.endsWith(tail) && len - tail.length() >= 2) {
+ candidates.add(len - tail.length());
+ }
+ }
+ }
+ candidates.add(balancedSplit(text, em)); // 兜底:按宽度均衡拆分
+ for (int candidate : candidates) {
+ // 断点既要语义合理,也要保证两行都放得下,否则 Word 还会再折一次
+ if (candidate > 0 && candidate < len && isSafeBreak(text, candidate)
+ && textWidth(text.substring(0, candidate), em) <= availTw
+ && textWidth(text.substring(candidate), em) <= availTw) {
+ return candidate;
+ }
+ }
+ return -1;
+ }
+
+ /** 按宽度取中间位置(中间偏后断行,避免首行过短) */
+ private static int balancedSplit(String text, int em) {
+ int len = text.length();
+ int half = textWidth(text, em) / 2;
+ int width = 0;
+ for (int i = 0; i < len; i++) {
+ width += charWidth(text.charAt(i), em);
+ if (width >= half) {
+ return Math.min(len - 1, i + 1);
+ }
+ }
+ return Math.min(len - 1, (len + 1) / 2);
+ }
+
+ /** 末尾括号单元(「(个)」「(项)」)的起始下标,没有则返回 -1 */
+ private static int trailingBracketStart(String text) {
+ int len = text.length();
+ if (len < 3) {
+ return -1;
+ }
+ char last = text.charAt(len - 1);
+ if (last != ')' && last != ')') {
+ return -1;
+ }
+ for (int i = len - 2; i >= 0; i--) {
+ char c = text.charAt(i);
+ if (c == '(' || c == '(') {
+ return (len - i <= 4) ? i : -1; // 括号单元不超过 4 个字符
+ }
+ }
+ return -1;
+ }
+
+ /** 文本宽度(twips):全角按 1 个字宽、半角按 0.5 个字宽估算 */
+ private static int textWidth(String text, int em) {
+ int width = 0;
+ for (int i = 0; i < text.length(); i++) {
+ width += charWidth(text.charAt(i), em);
+ }
+ return width;
+ }
+
+ private static int charWidth(char c, int em) {
+ return isFullWidth(c) ? em : em / 2;
+ }
+
+ /** 全角字符(汉字/全角标点/假名/韩文等),半角(英文、数字、半角符号)按半宽计 */
+ private static boolean isFullWidth(char c) {
+ return (c >= 0x1100 && c <= 0x115F)
+ || (c >= 0x2E80 && c <= 0xA4CF)
+ || (c >= 0xAC00 && c <= 0xD7A3)
+ || (c >= 0xF900 && c <= 0xFAFF)
+ || (c >= 0xFE30 && c <= 0xFE6F)
+ || (c >= 0xFF00 && c <= 0xFF60)
+ || (c >= 0xFFE0 && c <= 0xFFE6);
+ }
+
+ /** 纯中文文本(不含半角字符):只有这类文本才插入显式换行,避免拆开英文单词/数字 */
+ private static boolean isCjkText(String text) {
+ for (int i = 0; i < text.length(); i++) {
+ if (!isFullWidth(text.charAt(i))) {
+ return false;
+ }
}
+ return true;
}
// ============================================================================
diff --git a/inspect-metadata/src/main/resources/mapper/eqpbook/BasedataEqpBookChannelMapper.xml b/inspect-metadata/src/main/resources/mapper/eqpbook/BasedataEqpBookChannelMapper.xml
index e98c59b..765ffd6 100644
--- a/inspect-metadata/src/main/resources/mapper/eqpbook/BasedataEqpBookChannelMapper.xml
+++ b/inspect-metadata/src/main/resources/mapper/eqpbook/BasedataEqpBookChannelMapper.xml
@@ -15,6 +15,7 @@
+
@@ -66,6 +67,7 @@
be.area_name,
be.station_name,
be.station_code,
+ be.patrol_device_name as eqpBookName,
bec.channel_id,
bec.channel_code,
bec.up_system_device_code,