5 changed files with 756 additions and 1 deletions
@ -0,0 +1,83 @@ |
|||||||
|
package apelet.association.controller; |
||||||
|
|
||||||
|
import apelet.association.service.QuotationPdfService; |
||||||
|
import apelet.common.core.exception.MyRuntimeException; |
||||||
|
import org.springframework.beans.factory.annotation.Autowired; |
||||||
|
import org.springframework.web.bind.annotation.GetMapping; |
||||||
|
import org.springframework.web.bind.annotation.RequestMapping; |
||||||
|
import org.springframework.web.bind.annotation.RequestParam; |
||||||
|
import org.springframework.web.bind.annotation.RestController; |
||||||
|
|
||||||
|
import javax.servlet.http.HttpServletResponse; |
||||||
|
import java.io.IOException; |
||||||
|
import java.io.OutputStream; |
||||||
|
import java.net.URLEncoder; |
||||||
|
import java.nio.charset.StandardCharsets; |
||||||
|
|
||||||
|
/** |
||||||
|
* 报价单 Word→PDF 下载接口。 |
||||||
|
* |
||||||
|
* <p>触发链路:报价单<b>列表页</b>"打印PDF"按钮 → QuotationPdfPlugin 取当前行 id、 |
||||||
|
* 拼绝对下载地址 → 前端收到 DOWNLOAD_URL 事件跳转下载 → 本接口。</p> |
||||||
|
* |
||||||
|
* <p>本类只做 HTTP 层:调 QuotationPdfService 拿 PDF 字节,设置响应头后写出。 |
||||||
|
* 业务逻辑(取附件模板→POI 渲染→LibreOffice 转 PDF)全部在 QuotationPdfService。</p> |
||||||
|
* |
||||||
|
* <p>请求参数:</p> |
||||||
|
* <ul> |
||||||
|
* <li>id — 报价单 id(必填)。</li> |
||||||
|
* </ul> |
||||||
|
* |
||||||
|
* @author hz |
||||||
|
* @date 2026-08-19 |
||||||
|
*/ |
||||||
|
@RestController |
||||||
|
@RequestMapping("/api/quotationPdf") |
||||||
|
public class QuotationPdfController { |
||||||
|
|
||||||
|
@Autowired |
||||||
|
private QuotationPdfService quotationPdfService; |
||||||
|
|
||||||
|
/** |
||||||
|
* 下载报价单 PDF。 |
||||||
|
* |
||||||
|
* <p>返回 application/pdf 附件流;业务或转换失败时抛 MyRuntimeException, |
||||||
|
* 由全局异常处理器转成友好错误 JSON(不 500)。</p> |
||||||
|
*/ |
||||||
|
@GetMapping("/downloadPdf") |
||||||
|
public void downloadPdf(@RequestParam("id") String quotationId, |
||||||
|
HttpServletResponse response) { |
||||||
|
// 1. 生成 PDF 字节(查数据 → 取附件模板 → 渲染 → LibreOffice 转 PDF)
|
||||||
|
byte[] pdfBytes; |
||||||
|
try { |
||||||
|
pdfBytes = quotationPdfService.downloadPdf(quotationId); |
||||||
|
} catch (MyRuntimeException e) { |
||||||
|
// 业务异常(无附件/报价单不存在等):原样抛出,交给全局异常处理
|
||||||
|
throw e; |
||||||
|
} catch (Exception e) { |
||||||
|
// 其余异常:包一层,避免把内部堆栈直接抛给前端
|
||||||
|
throw new MyRuntimeException("导出失败: " + e.getMessage()); |
||||||
|
} |
||||||
|
|
||||||
|
// 2. 设置响应头,输出 PDF 附件
|
||||||
|
try { |
||||||
|
response.setContentType("application/pdf"); |
||||||
|
response.setCharacterEncoding("UTF-8"); |
||||||
|
// 中文文件名:URLEncoder 编码后走 RFC 5987 的 filename*=UTF-8'',兼容现代浏览器;
|
||||||
|
// 普通 filename 用 %20 代替空格,防止部分浏览器解析异常
|
||||||
|
String fileName = URLEncoder.encode("报价单.pdf", StandardCharsets.UTF_8.name()) |
||||||
|
.replaceAll("\\+", "%20"); |
||||||
|
response.setHeader("Content-Disposition", |
||||||
|
"attachment; filename=\"" + fileName + "\"; filename*=UTF-8''" + fileName); |
||||||
|
// 禁止浏览器缓存:每次打开都是最新 PDF,避免下载到旧的
|
||||||
|
response.setHeader("Pragma", "no-cache"); |
||||||
|
response.setHeader("Cache-Control", "no-cache"); |
||||||
|
response.setDateHeader("Expires", 0); |
||||||
|
OutputStream out = response.getOutputStream(); |
||||||
|
out.write(pdfBytes); |
||||||
|
out.flush(); |
||||||
|
} catch (IOException e) { |
||||||
|
throw new MyRuntimeException("输出 PDF 失败: " + e.getMessage()); |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,22 @@ |
|||||||
|
package apelet.association.service; |
||||||
|
|
||||||
|
/** |
||||||
|
* 报价单 Word→PDF 服务。 |
||||||
|
* |
||||||
|
* @author hz |
||||||
|
* @date 2026-08-19 |
||||||
|
*/ |
||||||
|
public interface QuotationPdfService { |
||||||
|
|
||||||
|
/** |
||||||
|
* 取报价单附件作为模板,渲染真实数据,转 PDF。 |
||||||
|
* |
||||||
|
* <p>签名图取报价单 managerperson(纯用户 id)对应的 xy_sys_user.signature_image, |
||||||
|
* 与当前登录用户无关。</p> |
||||||
|
* |
||||||
|
* @param quotationId 报价单 id |
||||||
|
* @return PDF 字节 |
||||||
|
* @throws Exception 渲染/转换失败 |
||||||
|
*/ |
||||||
|
byte[] downloadPdf(String quotationId) throws Exception; |
||||||
|
} |
||||||
@ -0,0 +1,568 @@ |
|||||||
|
package apelet.association.service.impl; |
||||||
|
|
||||||
|
import apelet.association.service.QuotationPdfService; |
||||||
|
import apelet.common.core.exception.MyRuntimeException; |
||||||
|
import apelet.common.core.object.ObjectValue; |
||||||
|
import apelet.common.generator.utils.OrmGenDataSourceUtil; |
||||||
|
import apelet.common.online.service.OnlineDictService; |
||||||
|
import com.alibaba.fastjson.JSON; |
||||||
|
import com.alibaba.fastjson.JSONArray; |
||||||
|
import com.alibaba.fastjson.JSONObject; |
||||||
|
import org.apache.poi.openxml4j.exceptions.InvalidFormatException; |
||||||
|
import org.apache.poi.xwpf.usermodel.IBodyElement; |
||||||
|
import org.apache.poi.xwpf.usermodel.XWPFDocument; |
||||||
|
import org.apache.poi.xwpf.usermodel.XWPFParagraph; |
||||||
|
import org.apache.poi.xwpf.usermodel.XWPFRun; |
||||||
|
import org.apache.poi.xwpf.usermodel.XWPFTable; |
||||||
|
import org.apache.poi.xwpf.usermodel.XWPFTableCell; |
||||||
|
import org.apache.poi.xwpf.usermodel.XWPFTableRow; |
||||||
|
import org.springframework.beans.factory.annotation.Autowired; |
||||||
|
import org.springframework.stereotype.Service; |
||||||
|
|
||||||
|
import java.io.ByteArrayInputStream; |
||||||
|
import java.io.ByteArrayOutputStream; |
||||||
|
import java.io.File; |
||||||
|
import java.io.IOException; |
||||||
|
import java.io.InputStream; |
||||||
|
import java.net.HttpURLConnection; |
||||||
|
import java.net.URL; |
||||||
|
import java.nio.file.Files; |
||||||
|
import java.util.ArrayList; |
||||||
|
import java.util.HashMap; |
||||||
|
import java.util.List; |
||||||
|
import java.util.Map; |
||||||
|
import java.util.concurrent.TimeUnit; |
||||||
|
import java.util.regex.Matcher; |
||||||
|
import java.util.regex.Pattern; |
||||||
|
import com.spire.doc.Document; |
||||||
|
import com.spire.doc.FileFormat; |
||||||
|
/** |
||||||
|
* 报价单 Word→PDF 服务实现。 |
||||||
|
* |
||||||
|
* <p>整体流程:</p> |
||||||
|
* <ol> |
||||||
|
* <li>按 id 查报价单主表;</li> |
||||||
|
* <li>取报价单附件(file_text 字段,平台附件 JSON)作为 Word 模板;</li> |
||||||
|
* <li>下载模板 docx 字节;</li> |
||||||
|
* <li>用 POI XWPF 替换模板里的 {@code #{xxx}} 文本占位符 + 插入签名图;</li> |
||||||
|
* <li>用 LibreOffice 本地命令行(soffice --headless --convert-to pdf)转成 PDF</li> |
||||||
|
* <li>返回 PDF 字节。</li> |
||||||
|
* </ol> |
||||||
|
* |
||||||
|
* <p>模板占位符约定(与报价单模板文件保持一致):</p> |
||||||
|
* <pre> |
||||||
|
* #{name} 报价单名称 |
||||||
|
* #{number} 报价单编号 |
||||||
|
* #{type} 项目类型(字典翻译后文本) |
||||||
|
* #{create_time} 创建时间(取日期部分 yyyy-MM-dd) |
||||||
|
* #{qty} 总报价 |
||||||
|
* #{managerperson} 报价经理姓名(xy_sys_user.show_name) |
||||||
|
* #{personimage} 签名图(报价单 managerperson 对应用户的 signature_image,图片占位符) |
||||||
|
* </pre> |
||||||
|
* |
||||||
|
* <p>模板的获取方式:把 {@code quotation_template.docx} 上传成某条报价单的附件(file_text 字段)。 |
||||||
|
* 附件若为 .pdf 则直接透传返回,不渲染;只有 .docx/.doc 才走渲染+转换。</p> |
||||||
|
* |
||||||
|
* <p>占位符定位方式(同 zyu QuotePrintService):docx 是嵌套结构(文档→段落/表格→行→单元格→段落), |
||||||
|
* 先用 {@link #getAllParagraphs} 把"正文段落 + 所有表格内段落"拍平成一张 List, |
||||||
|
* 后续替换只需一层循环遍历段落,run 的处理在各方法内部完成。</p> |
||||||
|
* |
||||||
|
* @author hz |
||||||
|
* @date 2026-08-19 |
||||||
|
*/ |
||||||
|
@Service |
||||||
|
public class QuotationPdfServiceImpl implements QuotationPdfService { |
||||||
|
|
||||||
|
@Autowired |
||||||
|
private OrmGenDataSourceUtil ormGenDataSourceUtil; |
||||||
|
@Autowired |
||||||
|
private OnlineDictService onlineDictService; |
||||||
|
|
||||||
|
/** 模板文本占位符正则:匹配 #{xxx} */ |
||||||
|
private static final Pattern PLACEHOLDER_PATTERN = Pattern.compile("#\\{(.+?)\\}"); |
||||||
|
|
||||||
|
@Override |
||||||
|
public byte[] downloadPdf(String quotationId) throws Exception { |
||||||
|
// 1. 查报价单主数据(queryOne 内部会自动带出 F7/分录等对象字段)
|
||||||
|
ObjectValue quotation = ormGenDataSourceUtil.queryOne("quotation", quotationId); |
||||||
|
if (quotation == null) { |
||||||
|
throw new MyRuntimeException("报价单不存在: " + quotationId); |
||||||
|
} |
||||||
|
|
||||||
|
// 2. 模板 = 报价单附件(file_text 字段存的是平台附件 JSON,如
|
||||||
|
// [{"baseUrl":"https://oss...","uploadPath":"xxx","filename":"quotation_template.docx",...}]
|
||||||
|
// 附件上传组件保存的就是这个结构)
|
||||||
|
Attachment attachment = parseAttachment(quotation.getString("file_text")); |
||||||
|
if (attachment == null || !hasText(attachment.url)) { |
||||||
|
throw new MyRuntimeException("该报价单未上传模板附件,请先在报价单附件里上传 docx 模板"); |
||||||
|
} |
||||||
|
|
||||||
|
// 3. 下载模板文件字节(可能来自 OSS,走 HTTP GET)
|
||||||
|
byte[] fileBytes = downloadFile(attachment.url); |
||||||
|
if (fileBytes == null || fileBytes.length == 0) { |
||||||
|
throw new MyRuntimeException("模板附件下载失败或文件为空"); |
||||||
|
} |
||||||
|
|
||||||
|
// 4. 附件本身就是 PDF:没必要渲染,直接透传;
|
||||||
|
// 否则用模板渲染出 docx,再交给 LibreOffice 转 PDF
|
||||||
|
if (isPdf(attachment.filename)) { |
||||||
|
return fileBytes; |
||||||
|
} |
||||||
|
byte[] docxBytes = renderTemplate(fileBytes, quotation); |
||||||
|
return docxToPdf(docxBytes); |
||||||
|
} |
||||||
|
|
||||||
|
// ==================== 模板渲染 ====================
|
||||||
|
|
||||||
|
/** |
||||||
|
* 用报价单真实数据替换模板占位符,输出填充后的 docx 字节。 |
||||||
|
* |
||||||
|
* <p>替换顺序有讲究(同 zyu):<b>先插签名图、再替换文本</b>。 |
||||||
|
* 图片替换需要定位到 {@code #{personimage}} 的原始文本,一旦文本先被替换, |
||||||
|
* 占位符就没了;而先插图时会把 personimage 占位符清掉,文本替换阶段自然不会再碰它, |
||||||
|
* 所以文本替换逻辑里不需要再对 personimage 做特殊跳过。</p> |
||||||
|
* |
||||||
|
* @param templateBytes 模板 docx 字节 |
||||||
|
* @param quotation 报价单数据(签名图取 managerperson 对应用户的 signature_image) |
||||||
|
*/ |
||||||
|
private byte[] renderTemplate(byte[] templateBytes, ObjectValue quotation) throws Exception { |
||||||
|
// 组装文本占位符的数据
|
||||||
|
Map<String, Object> dataMap = new HashMap<>(); |
||||||
|
dataMap.put("name", quotation.getString("name")); |
||||||
|
dataMap.put("number", quotation.getString("number")); |
||||||
|
// create_time 是完整时间戳,模板里一般只要日期部分,取前 10 位(yyyy-MM-dd)
|
||||||
|
String createTime = quotation.getString("create_time"); |
||||||
|
dataMap.put("create_time", hasText(createTime) && createTime.length() >= 10 ? createTime.substring(0, 10) : createTime); |
||||||
|
dataMap.put("qty", quotation.getString("qty")); |
||||||
|
|
||||||
|
// 项目类型:类型字段存的是字典 id,翻译成字典文本;
|
||||||
|
// 若为多选(逗号分隔,如 "1,2")整串翻译不到,则保留原值,避免空白
|
||||||
|
Map<String, String> dictMap = onlineDictService.getDictMap(2052225870772310016L); // 项目类型字典
|
||||||
|
Object typeObj = quotation.get("type", ""); |
||||||
|
String typeRaw = typeObj == null ? "" : String.valueOf(typeObj); |
||||||
|
String typeVal = hasText(typeRaw) ? dictMap.get(typeRaw) : null; |
||||||
|
dataMap.put("type", hasText(typeVal) ? typeVal : typeRaw); |
||||||
|
|
||||||
|
// 报价经理:优先取 F7 对象(表单里配了关联字段),查对应表拿 show_name;
|
||||||
|
// 兼容库里存纯数字 id 的情况(直接当 xy_sys_user 主键查)
|
||||||
|
String managerName = resolveManagerName(quotation); |
||||||
|
dataMap.put("managerperson", managerName == null ? "" : managerName); |
||||||
|
|
||||||
|
// 签名图:取报价单 managerperson(纯用户 id)对应的 xy_sys_user.signature_image,
|
||||||
|
// 而不是当前登录用户;解析不到或下载失败时保持 null,图片占位符被清空、不插图,PDF 照常生成
|
||||||
|
byte[] imageBytes = null; |
||||||
|
ObjectValue managerUser = resolveManagerUser(quotation); |
||||||
|
if (managerUser != null) { |
||||||
|
String imgUrl = getUrl(managerUser.getString("signature_image")); |
||||||
|
if (hasText(imgUrl)) { |
||||||
|
imageBytes = downloadFile(imgUrl); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
try (InputStream tplIs = new ByteArrayInputStream(templateBytes); |
||||||
|
XWPFDocument document = new XWPFDocument(tplIs)) { |
||||||
|
// 1) 先插签名图(此时占位符还在原文里)
|
||||||
|
// 宽度/高度单位是 px,POI addPicture 需要 EMU,1px = 9525 EMU
|
||||||
|
replaceImageInAllParagraphs(document, "personimage", imageBytes, 100, 100); |
||||||
|
// 2) 再替换文本占位符(personimage 已被清掉,不会被误替换)
|
||||||
|
replaceTextInAllParagraphs(document, dataMap); |
||||||
|
ByteArrayOutputStream out = new ByteArrayOutputStream(); |
||||||
|
document.write(out); |
||||||
|
return out.toByteArray(); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* 报价经理姓名(xy_sys_user.show_name),解析不到返回 null(模板里该占位符留空)。 |
||||||
|
*/ |
||||||
|
private String resolveManagerName(ObjectValue quotation) { |
||||||
|
ObjectValue user = resolveManagerUser(quotation); |
||||||
|
return user == null ? null : user.getString("show_name"); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* 报价经理对应的用户记录(姓名、签名图共用): |
||||||
|
* <ol> |
||||||
|
* <li>若 managerperson 是 F7 对象({tableName, id}),按关联表名+id 查;</li> |
||||||
|
* <li>否则若存的是纯数字 id,直接当 xy_sys_user 主键查(当前数据即为此形态);</li> |
||||||
|
* <li>都解析不到返回 null(签名图/姓名留空,不阻断整单 PDF)。</li> |
||||||
|
* </ol> |
||||||
|
*/ |
||||||
|
private ObjectValue resolveManagerUser(ObjectValue quotation) { |
||||||
|
try { |
||||||
|
ObjectValue managerperson = quotation.getObjectValue("managerperson"); |
||||||
|
if (managerperson != null) { |
||||||
|
String table = managerperson.getTableName(); |
||||||
|
String id = managerperson.getString("id"); |
||||||
|
if (hasText(table) && hasText(id)) { |
||||||
|
return ormGenDataSourceUtil.queryOne(table, id); |
||||||
|
} |
||||||
|
return null; |
||||||
|
} |
||||||
|
Object raw = quotation.get("managerperson"); |
||||||
|
if (raw != null && String.valueOf(raw).trim().matches("\\d+")) { |
||||||
|
return ormGenDataSourceUtil.queryOne("xy_sys_user", String.valueOf(raw).trim()); |
||||||
|
} |
||||||
|
return null; |
||||||
|
} catch (Exception e) { |
||||||
|
// 关联查询失败不阻断整单 PDF,返回 null 让签名图/姓名留白
|
||||||
|
return null; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// ==================== 段落收集与占位符替换 ====================
|
||||||
|
|
||||||
|
/** |
||||||
|
* 收集文档里<b>所有</b>段落:正文段落 + 所有表格单元格内的段落。 |
||||||
|
* |
||||||
|
* <p>docx 是嵌套容器(文档→段落/表格→行→单元格→段落),占位符可能在正文, |
||||||
|
* 也可能在表格里(报价单明细常在表格中)。把两层嵌套拆箱成一张扁平 List 后, |
||||||
|
* 调用方只需一层循环即可遍历所有段落,run 的处理在各方法内部完成。</p> |
||||||
|
*/ |
||||||
|
private List<XWPFParagraph> getAllParagraphs(XWPFDocument document) { |
||||||
|
List<XWPFParagraph> list = new ArrayList<>(); |
||||||
|
for (IBodyElement el : document.getBodyElements()) { |
||||||
|
if (el instanceof XWPFParagraph) { |
||||||
|
// 正文段落,直接收集
|
||||||
|
list.add((XWPFParagraph) el); |
||||||
|
} else if (el instanceof XWPFTable) { |
||||||
|
// 表格:逐行逐单元格取出里面的段落
|
||||||
|
for (XWPFTableRow row : ((XWPFTable) el).getRows()) { |
||||||
|
for (XWPFTableCell cell : row.getTableCells()) { |
||||||
|
list.addAll(cell.getParagraphs()); |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
return list; |
||||||
|
} |
||||||
|
|
||||||
|
/** 对所有段落做文本占位符替换(正文 + 表格一次搞定) */ |
||||||
|
private void replaceTextInAllParagraphs(XWPFDocument document, Map<String, Object> dataMap) { |
||||||
|
for (XWPFParagraph paragraph : getAllParagraphs(document)) { |
||||||
|
replaceInParagraph(paragraph, dataMap); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* 单个段落的文本占位符替换。 |
||||||
|
* |
||||||
|
* <p>把段内所有 run 的文本<b>拼接</b>后再正则匹配,再按偏移整体回写—— |
||||||
|
* 这样能处理占位符被 word 拆成多个 run 的情况(如 {@code #{create_time}} |
||||||
|
* 被拆成 {@code #{ / create_time / }} 三截)。</p> |
||||||
|
* |
||||||
|
* <p>回写方式:第一个 run 保留其格式写入整段新文本,其余 run 清空。</p> |
||||||
|
*/ |
||||||
|
private void replaceInParagraph(XWPFParagraph paragraph, Map<String, Object> dataMap) { |
||||||
|
List<XWPFRun> runs = paragraph.getRuns(); |
||||||
|
if (runs.isEmpty()) { |
||||||
|
return; |
||||||
|
} |
||||||
|
// 拼接所有 run 文本
|
||||||
|
StringBuilder sb = new StringBuilder(); |
||||||
|
for (XWPFRun run : runs) { |
||||||
|
String t = run.text(); |
||||||
|
sb.append(t == null ? "" : t); |
||||||
|
} |
||||||
|
String full = sb.toString(); |
||||||
|
if (full.indexOf("#{") < 0) { |
||||||
|
return; |
||||||
|
} |
||||||
|
// 逐个替换 #{xxx},未提供键的替换为空
|
||||||
|
Matcher m = PLACEHOLDER_PATTERN.matcher(full); |
||||||
|
if (!m.find()) { |
||||||
|
return; |
||||||
|
} |
||||||
|
m.reset(); |
||||||
|
StringBuffer replaced = new StringBuffer(); |
||||||
|
while (m.find()) { |
||||||
|
Object val = dataMap.get(m.group(1).trim()); |
||||||
|
// quoteReplacement 防止替换值里的 $ / \ 被 appendReplacement 转义
|
||||||
|
m.appendReplacement(replaced, val == null ? "" : Matcher.quoteReplacement(val.toString())); |
||||||
|
} |
||||||
|
m.appendTail(replaced); |
||||||
|
// 整体回写:第一个 run 保留其格式,其余 run 清空
|
||||||
|
runs.get(0).setText(replaced.toString(), 0); |
||||||
|
for (int i = 1; i < runs.size(); i++) { |
||||||
|
runs.get(i).setText("", 0); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* 对所有段落做图片占位符替换(正文 + 表格一次搞定)。 |
||||||
|
* |
||||||
|
* <p>imageBytes 为 null(用户没签名图/下载失败)时只清掉占位符文本、不插图, |
||||||
|
* 保证 PDF 照常生成、不会残留 {@code #{personimage}} 字面量。</p> |
||||||
|
*/ |
||||||
|
private void replaceImageInAllParagraphs(XWPFDocument document, String placeholderKey, byte[] imageBytes, int width, int height) throws IOException, InvalidFormatException { |
||||||
|
String placeholder = "#{" + placeholderKey + "}"; |
||||||
|
for (XWPFParagraph paragraph : getAllParagraphs(document)) { |
||||||
|
String text = paragraph.getText(); |
||||||
|
if (text == null || !text.contains(placeholder)) { |
||||||
|
continue; |
||||||
|
} |
||||||
|
for (XWPFRun run : paragraph.getRuns()) { |
||||||
|
String runText = run.text(); |
||||||
|
if (runText == null || !runText.contains(placeholder)) { |
||||||
|
continue; |
||||||
|
} |
||||||
|
// 只把占位符清掉,保留同 run 里的其他文字(如"负责人签字:")
|
||||||
|
run.setText(runText.replace(placeholder, ""), 0); |
||||||
|
if (imageBytes != null) { |
||||||
|
run.addPicture(new ByteArrayInputStream(imageBytes), XWPFDocument.PICTURE_TYPE_PNG, |
||||||
|
placeholderKey + ".png", width * 9525, height * 9525); |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// ==================== LibreOffice 本地转 PDF ====================
|
||||||
|
|
||||||
|
// /**
|
||||||
|
// * 用 LibreOffice 把 docx 字节转换为 PDF 字节。
|
||||||
|
// *
|
||||||
|
// * <p>核心命令:{@code soffice --headless --convert-to pdf --outdir <临时目录> <docx>}。</p>
|
||||||
|
// * <ul>
|
||||||
|
// * <li>--headless:无界面模式,适合服务器调用;</li>
|
||||||
|
// * <li>-env:UserInstallation 指定独立配置目录:LibreOffice 同时只允许一个实例持有用户配置,
|
||||||
|
// * 不指定的话并发/多次调用会互相抢锁卡住;</li>
|
||||||
|
// * <li>转换可能较慢(首次启动要初始化),超时 120s 后强制杀掉进程;</li>
|
||||||
|
// * <li>转换完成后清掉临时目录,避免磁盘堆积。</li>
|
||||||
|
// * </ul>
|
||||||
|
// *
|
||||||
|
// * <p>需要运行环境安装 LibreOffice(本机:C:/Program Files/LibreOffice/program/soffice.exe)。</p>
|
||||||
|
// */
|
||||||
|
// private byte[] docxToPdf(byte[] docxBytes) throws Exception {
|
||||||
|
// // 独立临时目录 + 独立 profile,避免与其他 soffice 实例文件锁冲突
|
||||||
|
// File tmpDir = new File(System.getProperty("java.io.tmpdir"), "quote_pdf_" + System.currentTimeMillis());
|
||||||
|
// if (!tmpDir.mkdirs()) {
|
||||||
|
// throw new IOException("创建临时目录失败: " + tmpDir);
|
||||||
|
// }
|
||||||
|
// try {
|
||||||
|
// File docxFile = new File(tmpDir, "quote.docx");
|
||||||
|
// Files.write(docxFile.toPath(), docxBytes);
|
||||||
|
//
|
||||||
|
// ProcessBuilder pb = new ProcessBuilder(
|
||||||
|
// findSoffice(),
|
||||||
|
// "--headless",
|
||||||
|
// "--convert-to", "pdf",
|
||||||
|
// "--outdir", tmpDir.getAbsolutePath(),
|
||||||
|
// "-env:UserInstallation=file:///" + tmpDir.getAbsolutePath().replace("\\", "/") + "/lo_profile",
|
||||||
|
// docxFile.getAbsolutePath());
|
||||||
|
// // 把子进程输出重定向合并到标准输出,下面统一读走,避免管道缓冲区满导致子进程阻塞
|
||||||
|
// pb.redirectErrorStream(true);
|
||||||
|
// Process p = pb.start();
|
||||||
|
// try (InputStream is = p.getInputStream()) {
|
||||||
|
// byte[] buf = new byte[8192];
|
||||||
|
// while (is.read(buf) != -1) {
|
||||||
|
// // 消耗输出,避免管道阻塞
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
// if (!p.waitFor(120, TimeUnit.SECONDS)) {
|
||||||
|
// p.destroyForcibly();
|
||||||
|
// throw new IOException("LibreOffice 转换超时");
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// // 转换输出文件名为输入名去扩展名 + .pdf,即 quote.pdf
|
||||||
|
// File pdfFile = new File(tmpDir, "quote.pdf");
|
||||||
|
// if (!pdfFile.exists()) {
|
||||||
|
// throw new IOException("LibreOffice 转换后未找到 PDF,请确认已安装 LibreOffice");
|
||||||
|
// }
|
||||||
|
// return Files.readAllBytes(pdfFile.toPath());
|
||||||
|
// } finally {
|
||||||
|
// deleteDir(tmpDir);
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
|
||||||
|
private byte[] docxToPdf(byte[] docxBytes) throws Exception { |
||||||
|
// 用 Spire.Doc 直接转换,无需临时文件和命令行
|
||||||
|
try (ByteArrayOutputStream pdfOutputStream = new ByteArrayOutputStream()) { |
||||||
|
// 1. 从字节数组加载 docx(正确用法)
|
||||||
|
Document document = new Document(); |
||||||
|
document.loadFromStream(new ByteArrayInputStream(docxBytes), FileFormat.Docx); |
||||||
|
|
||||||
|
// 2. 保存为 PDF 到内存流
|
||||||
|
document.saveToStream(pdfOutputStream, FileFormat.PDF); |
||||||
|
|
||||||
|
// 3. 返回 PDF 字节
|
||||||
|
return pdfOutputStream.toByteArray(); |
||||||
|
} catch (Exception e) { |
||||||
|
throw new IOException("Spire.Doc 转换 PDF 失败: " + e.getMessage(), e); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* 定位 soffice 可执行文件,依次探测: |
||||||
|
* 1) JVM 属性 soffice.path(启动时 -Dsoffice.path=xxx 指定); |
||||||
|
* 2) 环境变量 SOFFICE_HOME; |
||||||
|
* 3) Windows 常见安装路径; |
||||||
|
* 4) 最后回退 PATH 里的 soffice。 |
||||||
|
*/ |
||||||
|
private String findSoffice() { |
||||||
|
String sofficePath = System.getProperty("soffice.path"); |
||||||
|
if (hasText(sofficePath) && new File(sofficePath).exists()) { |
||||||
|
return sofficePath; |
||||||
|
} |
||||||
|
String sofficeHome = System.getenv("SOFFICE_HOME"); |
||||||
|
if (hasText(sofficeHome)) { |
||||||
|
File f = new File(sofficeHome, "program/soffice.exe"); |
||||||
|
if (f.exists()) { |
||||||
|
return f.getAbsolutePath(); |
||||||
|
} |
||||||
|
} |
||||||
|
String[] candidates = { |
||||||
|
"C:/Program Files/LibreOffice/program/soffice.exe", |
||||||
|
"C:/Program Files (x86)/LibreOffice/program/soffice.exe", |
||||||
|
}; |
||||||
|
for (String c : candidates) { |
||||||
|
if (new File(c).exists()) { |
||||||
|
return c; |
||||||
|
} |
||||||
|
} |
||||||
|
return "soffice"; |
||||||
|
} |
||||||
|
|
||||||
|
/** 递归删除临时目录(含子目录/文件),转换结束清理磁盘 */ |
||||||
|
private void deleteDir(File dir) { |
||||||
|
File[] files = dir.listFiles(); |
||||||
|
if (files != null) { |
||||||
|
for (File f : files) { |
||||||
|
if (f.isDirectory()) { |
||||||
|
deleteDir(f); |
||||||
|
} else { |
||||||
|
f.delete(); |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
dir.delete(); |
||||||
|
} |
||||||
|
|
||||||
|
// ==================== 文件解析与下载 ====================
|
||||||
|
|
||||||
|
/** |
||||||
|
* 解析平台附件 JSON,返回下载地址 + 文件名。 |
||||||
|
* |
||||||
|
* <p>兼容两种格式:</p> |
||||||
|
* <ul> |
||||||
|
* <li>数组:{@code [{"baseUrl":"https://oss...","uploadPath":"xxx/","filename":"a.docx"}]}</li> |
||||||
|
* <li>对象:{@code {"baseUrl":"...","uploadPath":"...","filename":"a.docx"}}</li> |
||||||
|
* </ul> |
||||||
|
* |
||||||
|
* <p>上传路径以 ./ 开头的是本地相对路径(如 ./zz-resource/upload-files/...), |
||||||
|
* 没有 OSS 地址拼不出来,返回 null(视为"没有可用模板")。</p> |
||||||
|
*/ |
||||||
|
private Attachment parseAttachment(String fileJson) { |
||||||
|
if (!hasText(fileJson)) { |
||||||
|
return null; |
||||||
|
} |
||||||
|
try { |
||||||
|
Object obj = JSON.parse(fileJson); |
||||||
|
JSONObject jo = null; |
||||||
|
if (obj instanceof JSONArray) { |
||||||
|
JSONArray arr = (JSONArray) obj; |
||||||
|
if (arr.isEmpty()) { |
||||||
|
return null; |
||||||
|
} |
||||||
|
jo = arr.getJSONObject(0); |
||||||
|
} else if (obj instanceof JSONObject) { |
||||||
|
jo = (JSONObject) obj; |
||||||
|
} else { |
||||||
|
return null; |
||||||
|
} |
||||||
|
String baseUrl = jo.getString("baseUrl"); |
||||||
|
String uploadPath = jo.getString("uploadPath"); |
||||||
|
String filename = jo.getString("filename"); |
||||||
|
if (!hasText(baseUrl) || !hasText(uploadPath) || !hasText(filename)) { |
||||||
|
return null; |
||||||
|
} |
||||||
|
if (uploadPath.trim().startsWith("./")) { |
||||||
|
return null; |
||||||
|
} |
||||||
|
Attachment attachment = new Attachment(); |
||||||
|
// 平台约定:OSS 完整访问地址 = baseUrl + "/" + uploadPath + "/" + filename
|
||||||
|
attachment.url = baseUrl + "/" + uploadPath + "/" + filename; |
||||||
|
attachment.filename = filename; |
||||||
|
return attachment; |
||||||
|
} catch (Exception e) { |
||||||
|
return null; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* 签名图路径解析,逻辑同 parseAttachment。 |
||||||
|
* 解析不到返回空串——签名图是可选项,缺了不影响出 PDF。 |
||||||
|
*/ |
||||||
|
private String getUrl(String signatureImage) { |
||||||
|
if (!hasText(signatureImage)) { |
||||||
|
return ""; |
||||||
|
} |
||||||
|
try { |
||||||
|
Object obj = JSON.parse(signatureImage); |
||||||
|
JSONObject jo = null; |
||||||
|
if (obj instanceof JSONArray) { |
||||||
|
JSONArray arr = (JSONArray) obj; |
||||||
|
if (arr.isEmpty()) { |
||||||
|
return ""; |
||||||
|
} |
||||||
|
jo = arr.getJSONObject(0); |
||||||
|
} else if (obj instanceof JSONObject) { |
||||||
|
jo = (JSONObject) obj; |
||||||
|
} else { |
||||||
|
return ""; |
||||||
|
} |
||||||
|
String baseUrl = jo.getString("baseUrl"); |
||||||
|
String uploadPath = jo.getString("uploadPath"); |
||||||
|
String filename = jo.getString("filename"); |
||||||
|
if (!hasText(baseUrl) || !hasText(uploadPath) || !hasText(filename)) { |
||||||
|
return ""; |
||||||
|
} |
||||||
|
if (uploadPath.trim().startsWith("./")) { |
||||||
|
return ""; |
||||||
|
} |
||||||
|
return baseUrl + "/" + uploadPath + "/" + filename; |
||||||
|
} catch (Exception e) { |
||||||
|
return ""; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
/** 下载远程文件字节(OSS 模板、签名图共用):GET + 连接/读取超时 + HTTP 200 校验 */ |
||||||
|
private byte[] downloadFile(String fileUrl) throws IOException { |
||||||
|
URL url = new URL(fileUrl); |
||||||
|
HttpURLConnection conn = (HttpURLConnection) url.openConnection(); |
||||||
|
conn.setRequestMethod("GET"); |
||||||
|
conn.setConnectTimeout(5000); |
||||||
|
conn.setReadTimeout(15000); |
||||||
|
try { |
||||||
|
int code = conn.getResponseCode(); |
||||||
|
if (code != HttpURLConnection.HTTP_OK) { |
||||||
|
throw new IOException("下载失败, HTTP " + code); |
||||||
|
} |
||||||
|
try (InputStream is = conn.getInputStream(); |
||||||
|
ByteArrayOutputStream baos = new ByteArrayOutputStream()) { |
||||||
|
byte[] buf = new byte[4096]; |
||||||
|
int len; |
||||||
|
while ((len = is.read(buf)) != -1) { |
||||||
|
baos.write(buf, 0, len); |
||||||
|
} |
||||||
|
return baos.toByteArray(); |
||||||
|
} |
||||||
|
} finally { |
||||||
|
conn.disconnect(); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
/** 附件后缀判断:.pdf 直接透传,不渲染不转换 */ |
||||||
|
private boolean isPdf(String filename) { |
||||||
|
return hasText(filename) && filename.toLowerCase().endsWith(".pdf"); |
||||||
|
} |
||||||
|
|
||||||
|
private boolean hasText(String s) { |
||||||
|
return s != null && !s.trim().isEmpty(); |
||||||
|
} |
||||||
|
|
||||||
|
/** 附件解析结果:下载地址 + 原文件名 */ |
||||||
|
private static class Attachment { |
||||||
|
String url; |
||||||
|
String filename; |
||||||
|
} |
||||||
|
} |
||||||
Loading…
Reference in new issue