5 changed files with 756 additions and 1 deletions
@ -0,0 +1,83 @@
@@ -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 @@
@@ -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 @@
@@ -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