From c7867f3030291bc658228626e84d1d2ee9a8d371 Mon Sep 17 00:00:00 2001 From: hz <1283027134@qq.com> Date: Mon, 31 Aug 2026 14:06:52 +0800 Subject: [PATCH] =?UTF-8?q?feat:=E6=89=93=E5=8D=B0=E5=8A=9F=E8=83=BD?= =?UTF-8?q?=EF=BC=8Cword=E8=BD=ACpdf?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- common/common-association/pom.xml | 8 +- .../controller/QuotationPdfController.java | 83 +++ .../plugin/quotation/OpenPopupPlugin.java | 76 +++ .../association/service/QuotationPdfService.java | 22 + .../service/impl/QuotationPdfServiceImpl.java | 568 +++++++++++++++++++++ 5 files changed, 756 insertions(+), 1 deletion(-) create mode 100644 common/common-association/src/main/java/apelet/association/controller/QuotationPdfController.java create mode 100644 common/common-association/src/main/java/apelet/association/service/QuotationPdfService.java create mode 100644 common/common-association/src/main/java/apelet/association/service/impl/QuotationPdfServiceImpl.java diff --git a/common/common-association/pom.xml b/common/common-association/pom.xml index 9e7b096..26e4512 100644 --- a/common/common-association/pom.xml +++ b/common/common-association/pom.xml @@ -74,7 +74,13 @@ poi-tl 1.12.2 - + + + e-iceblue + spire.doc.free + 14.3.1 + + diff --git a/common/common-association/src/main/java/apelet/association/controller/QuotationPdfController.java b/common/common-association/src/main/java/apelet/association/controller/QuotationPdfController.java new file mode 100644 index 0000000..e0c5c3b --- /dev/null +++ b/common/common-association/src/main/java/apelet/association/controller/QuotationPdfController.java @@ -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 下载接口。 + * + *

触发链路:报价单列表页"打印PDF"按钮 → QuotationPdfPlugin 取当前行 id、 + * 拼绝对下载地址 → 前端收到 DOWNLOAD_URL 事件跳转下载 → 本接口。

+ * + *

本类只做 HTTP 层:调 QuotationPdfService 拿 PDF 字节,设置响应头后写出。 + * 业务逻辑(取附件模板→POI 渲染→LibreOffice 转 PDF)全部在 QuotationPdfService。

+ * + *

请求参数:

+ * + * + * @author hz + * @date 2026-08-19 + */ +@RestController +@RequestMapping("/api/quotationPdf") +public class QuotationPdfController { + + @Autowired + private QuotationPdfService quotationPdfService; + + /** + * 下载报价单 PDF。 + * + *

返回 application/pdf 附件流;业务或转换失败时抛 MyRuntimeException, + * 由全局异常处理器转成友好错误 JSON(不 500)。

+ */ + @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()); + } + } +} diff --git a/common/common-association/src/main/java/apelet/association/plugin/quotation/OpenPopupPlugin.java b/common/common-association/src/main/java/apelet/association/plugin/quotation/OpenPopupPlugin.java index 8fb5886..fdc6325 100644 --- a/common/common-association/src/main/java/apelet/association/plugin/quotation/OpenPopupPlugin.java +++ b/common/common-association/src/main/java/apelet/association/plugin/quotation/OpenPopupPlugin.java @@ -4,10 +4,15 @@ package apelet.association.plugin.quotation; import apelet.common.core.object.ObjectValue; import apelet.common.online.abstractplugin.ListPlugin; import apelet.common.online.model.ShowParameter; +import apelet.common.online.model.constant.AttributeEnum; import apelet.common.online.model.constant.ShowTypeEnum; import apelet.common.online.model.constant.ViewStatus; +import org.springframework.web.context.request.RequestContextHolder; +import org.springframework.web.context.request.ServletRequestAttributes; +import javax.servlet.http.HttpServletRequest; import java.util.HashMap; +import java.util.List; import java.util.Map; /* @@ -49,5 +54,76 @@ public class OpenPopupPlugin extends ListPlugin { showParameter.setCustomParam(map); super.showForm(showParameter); } + + if ("打印".equals(widgetVariableName)) { + // 1. 取当前行:优先从框架 rowData 穿透取(ReportListPlugin 同款); + // 拿不到再回退按钮事件传入的对象 + Map row = getCurrentRow(); + if ((row == null || row.get("id") == null) && objectValue != null) { + row = objectValue.getValues(); + } + if (row == null || row.get("id") == null) { + this.showWarningMessage("请选择一条数据"); + this.cancelOperate(); + return; + } + + // 2. 拼下载地址(优先绝对地址 scheme://host:port/contextPath;拿不到请求时退回相对路径) + // 签名图由报价单 managerperson 决定,不依赖当前登录用户 + String url = buildUrl(String.valueOf(row.get("id"))); + + // 3. 事件:{url, type:"download"} → 前端下载该 URL + Map map = new HashMap<>(); + map.put("url", url); + map.put("type", "download"); + super.setWidgetAttribute("", AttributeEnum.DOWNLOAD_URL, map); + this.cancelOperate(); + } } + + private String buildUrl(String quotationId) { + String base = buildBaseUrl(); + return base + "/api/quotationPdf/downloadPdf?id=" + quotationId; + } + + /** + * 取当前行数据。rowData 结构为 {@code {tableWidgetId: [行Map]}}, + * 穿透外层 Map 找到第一个"值为 List"的项,取其第一行。 + */ + private Map getCurrentRow() { + try { + Map rowData = getDto().getModel().getRowData(); + if (rowData == null) { + return null; + } + for (Object val : rowData.values()) { + if (val instanceof List) { + List list = (List) val; + if (!list.isEmpty() && list.get(0) instanceof Map) { + return (Map) list.get(0); + } + } + } + } catch (Exception ignored) { + // 取行失败交给外层统一提示"请选择一条数据" + } + return null; + } + + /** 从当前请求拼协议+主机+端口+contextPath;异常时返回空串(退回相对地址) */ + private String buildBaseUrl() { + try { + ServletRequestAttributes attrs = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes(); + if (attrs == null || attrs.getRequest() == null) { + return ""; + } + HttpServletRequest request = attrs.getRequest(); + String ctx = request.getContextPath(); + return request.getScheme() + "://" + request.getServerName() + ":" + + request.getServerPort() + (ctx == null ? "" : ctx); + } catch (Exception e) { + return ""; + } + } + } diff --git a/common/common-association/src/main/java/apelet/association/service/QuotationPdfService.java b/common/common-association/src/main/java/apelet/association/service/QuotationPdfService.java new file mode 100644 index 0000000..c97d735 --- /dev/null +++ b/common/common-association/src/main/java/apelet/association/service/QuotationPdfService.java @@ -0,0 +1,22 @@ +package apelet.association.service; + +/** + * 报价单 Word→PDF 服务。 + * + * @author hz + * @date 2026-08-19 + */ +public interface QuotationPdfService { + + /** + * 取报价单附件作为模板,渲染真实数据,转 PDF。 + * + *

签名图取报价单 managerperson(纯用户 id)对应的 xy_sys_user.signature_image, + * 与当前登录用户无关。

+ * + * @param quotationId 报价单 id + * @return PDF 字节 + * @throws Exception 渲染/转换失败 + */ + byte[] downloadPdf(String quotationId) throws Exception; +} diff --git a/common/common-association/src/main/java/apelet/association/service/impl/QuotationPdfServiceImpl.java b/common/common-association/src/main/java/apelet/association/service/impl/QuotationPdfServiceImpl.java new file mode 100644 index 0000000..387e99b --- /dev/null +++ b/common/common-association/src/main/java/apelet/association/service/impl/QuotationPdfServiceImpl.java @@ -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 服务实现。 + * + *

整体流程:

+ *
    + *
  1. 按 id 查报价单主表;
  2. + *
  3. 取报价单附件(file_text 字段,平台附件 JSON)作为 Word 模板;
  4. + *
  5. 下载模板 docx 字节;
  6. + *
  7. 用 POI XWPF 替换模板里的 {@code #{xxx}} 文本占位符 + 插入签名图;
  8. + *
  9. 用 LibreOffice 本地命令行(soffice --headless --convert-to pdf)转成 PDF
  10. + *
  11. 返回 PDF 字节。
  12. + *
+ * + *

模板占位符约定(与报价单模板文件保持一致):

+ *
+ *   #{name}          报价单名称
+ *   #{number}        报价单编号
+ *   #{type}          项目类型(字典翻译后文本)
+ *   #{create_time}   创建时间(取日期部分 yyyy-MM-dd)
+ *   #{qty}           总报价
+ *   #{managerperson} 报价经理姓名(xy_sys_user.show_name)
+ *   #{personimage}   签名图(报价单 managerperson 对应用户的 signature_image,图片占位符)
+ * 
+ * + *

模板的获取方式:把 {@code quotation_template.docx} 上传成某条报价单的附件(file_text 字段)。 + * 附件若为 .pdf 则直接透传返回,不渲染;只有 .docx/.doc 才走渲染+转换。

+ * + *

占位符定位方式(同 zyu QuotePrintService):docx 是嵌套结构(文档→段落/表格→行→单元格→段落), + * 先用 {@link #getAllParagraphs} 把"正文段落 + 所有表格内段落"拍平成一张 List, + * 后续替换只需一层循环遍历段落,run 的处理在各方法内部完成。

+ * + * @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 字节。 + * + *

替换顺序有讲究(同 zyu):先插签名图、再替换文本。 + * 图片替换需要定位到 {@code #{personimage}} 的原始文本,一旦文本先被替换, + * 占位符就没了;而先插图时会把 personimage 占位符清掉,文本替换阶段自然不会再碰它, + * 所以文本替换逻辑里不需要再对 personimage 做特殊跳过。

+ * + * @param templateBytes 模板 docx 字节 + * @param quotation 报价单数据(签名图取 managerperson 对应用户的 signature_image) + */ + private byte[] renderTemplate(byte[] templateBytes, ObjectValue quotation) throws Exception { + // 组装文本占位符的数据 + Map 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 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"); + } + + /** + * 报价经理对应的用户记录(姓名、签名图共用): + *
    + *
  1. 若 managerperson 是 F7 对象({tableName, id}),按关联表名+id 查;
  2. + *
  3. 否则若存的是纯数字 id,直接当 xy_sys_user 主键查(当前数据即为此形态);
  4. + *
  5. 都解析不到返回 null(签名图/姓名留空,不阻断整单 PDF)。
  6. + *
+ */ + 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; + } + } + + // ==================== 段落收集与占位符替换 ==================== + + /** + * 收集文档里所有段落:正文段落 + 所有表格单元格内的段落。 + * + *

docx 是嵌套容器(文档→段落/表格→行→单元格→段落),占位符可能在正文, + * 也可能在表格里(报价单明细常在表格中)。把两层嵌套拆箱成一张扁平 List 后, + * 调用方只需一层循环即可遍历所有段落,run 的处理在各方法内部完成。

+ */ + private List getAllParagraphs(XWPFDocument document) { + List 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 dataMap) { + for (XWPFParagraph paragraph : getAllParagraphs(document)) { + replaceInParagraph(paragraph, dataMap); + } + } + + /** + * 单个段落的文本占位符替换。 + * + *

把段内所有 run 的文本拼接后再正则匹配,再按偏移整体回写—— + * 这样能处理占位符被 word 拆成多个 run 的情况(如 {@code #{create_time}} + * 被拆成 {@code #{ / create_time / }} 三截)。

+ * + *

回写方式:第一个 run 保留其格式写入整段新文本,其余 run 清空。

+ */ + private void replaceInParagraph(XWPFParagraph paragraph, Map dataMap) { + List 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); + } + } + + /** + * 对所有段落做图片占位符替换(正文 + 表格一次搞定)。 + * + *

imageBytes 为 null(用户没签名图/下载失败)时只清掉占位符文本、不插图, + * 保证 PDF 照常生成、不会残留 {@code #{personimage}} 字面量。

+ */ + 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 字节。 +// * +// *

核心命令:{@code soffice --headless --convert-to pdf --outdir <临时目录> }。

+// *
    +// *
  • --headless:无界面模式,适合服务器调用;
  • +// *
  • -env:UserInstallation 指定独立配置目录:LibreOffice 同时只允许一个实例持有用户配置, +// * 不指定的话并发/多次调用会互相抢锁卡住;
  • +// *
  • 转换可能较慢(首次启动要初始化),超时 120s 后强制杀掉进程;
  • +// *
  • 转换完成后清掉临时目录,避免磁盘堆积。
  • +// *
+// * +// *

需要运行环境安装 LibreOffice(本机:C:/Program Files/LibreOffice/program/soffice.exe)。

+// */ +// 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,返回下载地址 + 文件名。 + * + *

兼容两种格式:

+ *
    + *
  • 数组:{@code [{"baseUrl":"https://oss...","uploadPath":"xxx/","filename":"a.docx"}]}
  • + *
  • 对象:{@code {"baseUrl":"...","uploadPath":"...","filename":"a.docx"}}
  • + *
+ * + *

上传路径以 ./ 开头的是本地相对路径(如 ./zz-resource/upload-files/...), + * 没有 OSS 地址拼不出来,返回 null(视为"没有可用模板")。

+ */ + 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; + } +}