|
@@ -1,14 +1,16 @@
|
|
|
package org.dromara.main.service.impl;
|
|
package org.dromara.main.service.impl;
|
|
|
|
|
|
|
|
|
|
+import lombok.extern.slf4j.Slf4j;
|
|
|
import org.apache.poi.xwpf.usermodel.*;
|
|
import org.apache.poi.xwpf.usermodel.*;
|
|
|
import org.dromara.common.core.exception.ServiceException;
|
|
import org.dromara.common.core.exception.ServiceException;
|
|
|
|
|
+
|
|
|
import org.springframework.core.io.ClassPathResource;
|
|
import org.springframework.core.io.ClassPathResource;
|
|
|
import org.springframework.stereotype.Component;
|
|
import org.springframework.stereotype.Component;
|
|
|
-import fr.opensagres.poi.xwpf.converter.pdf.PdfConverter;
|
|
|
|
|
-import fr.opensagres.poi.xwpf.converter.pdf.PdfOptions;
|
|
|
|
|
|
|
|
|
|
-import java.io.ByteArrayOutputStream;
|
|
|
|
|
-import java.io.InputStream;
|
|
|
|
|
|
|
+import java.io.*;
|
|
|
|
|
+import java.util.ArrayList;
|
|
|
|
|
+import java.nio.file.Files;
|
|
|
|
|
+import java.nio.file.Path;
|
|
|
import java.util.List;
|
|
import java.util.List;
|
|
|
import java.util.Map;
|
|
import java.util.Map;
|
|
|
import java.util.regex.Matcher;
|
|
import java.util.regex.Matcher;
|
|
@@ -18,133 +20,336 @@ import java.util.regex.Pattern;
|
|
|
* Word 模板报告服务。
|
|
* Word 模板报告服务。
|
|
|
* <p>
|
|
* <p>
|
|
|
* 从 classpath 加载 Word 模板 (templates/report_template.docx),
|
|
* 从 classpath 加载 Word 模板 (templates/report_template.docx),
|
|
|
- * 将 {{placeholder}} 占位符替换为实际值,然后转换为 PDF 字节数组。
|
|
|
|
|
|
|
+ * 将 {{placeholder}} 占位符替换为实际值,
|
|
|
|
|
+ * 处理 checkbox 勾选框(Wingdings 字体),
|
|
|
|
|
+ * 然后通过 LibreOffice headless 转换为高保真 PDF。
|
|
|
*/
|
|
*/
|
|
|
|
|
+@Slf4j
|
|
|
@Component
|
|
@Component
|
|
|
public class WordReportService {
|
|
public class WordReportService {
|
|
|
|
|
|
|
|
private static final String TEMPLATE_PATH = "templates/report_template.docx";
|
|
private static final String TEMPLATE_PATH = "templates/report_template.docx";
|
|
|
private static final Pattern PLACEHOLDER_PATTERN = Pattern.compile("\\{\\{\\s*(\\w+)\\s*}}");
|
|
private static final Pattern PLACEHOLDER_PATTERN = Pattern.compile("\\{\\{\\s*(\\w+)\\s*}}");
|
|
|
|
|
+ /** CB_ 前缀的占位符,表示 checkbox 组,值格式为 "选项1|选项2|选项3|SELECTED:选中项" */
|
|
|
|
|
+ private static final Pattern CB_PLACEHOLDER_PATTERN = Pattern.compile("\\{\\{CB_(\\w+)}}");
|
|
|
|
|
+ private static final String DEBUG_DOCX_PATH = "report_debug_output.docx";
|
|
|
|
|
+
|
|
|
|
|
+ // Wingdings 字符:选中 ☑ = char 254 (0xFE), 未选中 ☐ = char 168 (0xA8)
|
|
|
|
|
+ private static final char WINGDINGS_CHECKED = (char) 0xFE;
|
|
|
|
|
+ private static final char WINGDINGS_UNCHECKED = (char) 0xA8;
|
|
|
|
|
+
|
|
|
|
|
+ private static final String[] SOFFICE_PATHS = {
|
|
|
|
|
+ "C:\\Program Files\\LibreOffice\\program\\soffice.exe",
|
|
|
|
|
+ "C:\\Program Files (x86)\\LibreOffice\\program\\soffice.exe",
|
|
|
|
|
+ "D:\\Program Files\\LibreOffice\\program\\soffice.exe",
|
|
|
|
|
+ "/usr/bin/soffice",
|
|
|
|
|
+ "/usr/local/bin/soffice",
|
|
|
|
|
+ };
|
|
|
|
|
|
|
|
/**
|
|
/**
|
|
|
* 根据占位符映射生成 PDF。
|
|
* 根据占位符映射生成 PDF。
|
|
|
*
|
|
*
|
|
|
- * @param placeholders key=占位符名称(不含花括号),value=要填入的值
|
|
|
|
|
|
|
+ * @param placeholders key=占位符名称, value=要填入的值。
|
|
|
|
|
+ * 对于 CB_ 前缀的 key,value 格式为 "选项1,选项2,选项3|SELECTED:选中项"
|
|
|
* @return PDF 文件字节数组
|
|
* @return PDF 文件字节数组
|
|
|
*/
|
|
*/
|
|
|
public byte[] generatePdf(Map<String, String> placeholders) {
|
|
public byte[] generatePdf(Map<String, String> placeholders) {
|
|
|
try (InputStream templateStream = new ClassPathResource(TEMPLATE_PATH).getInputStream();
|
|
try (InputStream templateStream = new ClassPathResource(TEMPLATE_PATH).getInputStream();
|
|
|
- XWPFDocument document = new XWPFDocument(templateStream);
|
|
|
|
|
- ByteArrayOutputStream pdfOut = new ByteArrayOutputStream()) {
|
|
|
|
|
|
|
+ XWPFDocument document = new XWPFDocument(templateStream)) {
|
|
|
|
|
|
|
|
// 1. 替换文档正文段落中的占位符
|
|
// 1. 替换文档正文段落中的占位符
|
|
|
for (XWPFParagraph paragraph : document.getParagraphs()) {
|
|
for (XWPFParagraph paragraph : document.getParagraphs()) {
|
|
|
replacePlaceholdersInParagraph(paragraph, placeholders);
|
|
replacePlaceholdersInParagraph(paragraph, placeholders);
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- // 2. 替换所有表格中的占位符
|
|
|
|
|
|
|
+ // 2. 替换所有表格中的占位符(包括 checkbox)
|
|
|
for (XWPFTable table : document.getTables()) {
|
|
for (XWPFTable table : document.getTables()) {
|
|
|
for (XWPFTableRow row : table.getRows()) {
|
|
for (XWPFTableRow row : table.getRows()) {
|
|
|
for (XWPFTableCell cell : row.getTableCells()) {
|
|
for (XWPFTableCell cell : row.getTableCells()) {
|
|
|
- for (XWPFParagraph paragraph : cell.getParagraphs()) {
|
|
|
|
|
|
|
+ for (XWPFParagraph paragraph : new ArrayList<>(cell.getParagraphs())) {
|
|
|
|
|
+ // 检查是否是 checkbox 占位符
|
|
|
|
|
+ String paraText = paragraph.getText();
|
|
|
|
|
+ if (paraText != null) {
|
|
|
|
|
+ Matcher cbMatcher = CB_PLACEHOLDER_PATTERN.matcher(paraText);
|
|
|
|
|
+ if (cbMatcher.find()) {
|
|
|
|
|
+ String cbKey = "CB_" + cbMatcher.group(1);
|
|
|
|
|
+ String cbValue = placeholders.getOrDefault(cbKey, "");
|
|
|
|
|
+ applyCheckboxParagraph(cell, paragraph, cbValue);
|
|
|
|
|
+ continue;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
replacePlaceholdersInParagraph(paragraph, placeholders);
|
|
replacePlaceholdersInParagraph(paragraph, placeholders);
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- // 3. 转换为 PDF
|
|
|
|
|
- PdfOptions options = PdfOptions.create();
|
|
|
|
|
- PdfConverter.getInstance().convert(document, pdfOut, options);
|
|
|
|
|
|
|
+ // 3. 保存到临时文件
|
|
|
|
|
+ Path tempDir = Files.createTempDirectory("report_");
|
|
|
|
|
+ Path docxPath = tempDir.resolve("report.docx");
|
|
|
|
|
+ try (FileOutputStream fos = new FileOutputStream(docxPath.toFile())) {
|
|
|
|
|
+ document.write(fos);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 调试:保存到项目目录
|
|
|
|
|
+ saveDebugDocx(document);
|
|
|
|
|
|
|
|
- return pdfOut.toByteArray();
|
|
|
|
|
|
|
+ // 4. LibreOffice 转 PDF
|
|
|
|
|
+ byte[] pdfBytes = convertToPdfWithLibreOffice(docxPath, tempDir);
|
|
|
|
|
+
|
|
|
|
|
+ // 5. 清理
|
|
|
|
|
+ cleanupTempDir(tempDir);
|
|
|
|
|
+
|
|
|
|
|
+ return pdfBytes;
|
|
|
|
|
+ } catch (ServiceException e) {
|
|
|
|
|
+ log.error("报告 PDF 生成失败: {}", e.getMessage(), e);
|
|
|
|
|
+ throw e;
|
|
|
} catch (Exception e) {
|
|
} catch (Exception e) {
|
|
|
- throw new ServiceException("生成报告PDF失败: " + e.getMessage());
|
|
|
|
|
|
|
+ log.error("报告 PDF 生成异常", e);
|
|
|
|
|
+ throw new ServiceException("生成报告PDF失败: " + (e.getMessage() == null ? e.getClass().getSimpleName() : e.getMessage()));
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
/**
|
|
|
- * 替换段落中的占位符。
|
|
|
|
|
- * <p>
|
|
|
|
|
- * 需要处理占位符可能被拆分到多个 run 中的情况,
|
|
|
|
|
- * 例如 "{{" 在 run1,"name" 在 run2,"}}" 在 run3。
|
|
|
|
|
|
|
+ * 处理 checkbox 组。
|
|
|
|
|
+ * value 格式: "选项1,选项2,选项3|SELECTED:选中项"
|
|
|
|
|
+ * 例如: "属实,不属实,无法核实|SELECTED:属实"
|
|
|
|
|
+ *
|
|
|
|
|
+ * 会在 cell 中生成多行,每行一个 checkbox + 标签,
|
|
|
|
|
+ * 选中项使用 Wingdings ☑ (char 254),未选中使用 ☐ (char 168)。
|
|
|
|
|
+ */
|
|
|
|
|
+ private void applyCheckboxParagraph(XWPFTableCell cell, XWPFParagraph existingParagraph, String value) {
|
|
|
|
|
+ if (value == null || value.isEmpty()) {
|
|
|
|
|
+ // 清空占位符文本
|
|
|
|
|
+ clearParagraphText(existingParagraph);
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 解析 value: "选项1,选项2,选项3|SELECTED:选中项"
|
|
|
|
|
+ String optionsPart = value;
|
|
|
|
|
+ String selected = "";
|
|
|
|
|
+ if (value.contains("|SELECTED:")) {
|
|
|
|
|
+ int idx = value.indexOf("|SELECTED:");
|
|
|
|
|
+ optionsPart = value.substring(0, idx);
|
|
|
|
|
+ selected = value.substring(idx + "|SELECTED:".length());
|
|
|
|
|
+ }
|
|
|
|
|
+ String[] options = optionsPart.split(",");
|
|
|
|
|
+
|
|
|
|
|
+ // 清空现有段落
|
|
|
|
|
+ clearParagraphText(existingParagraph);
|
|
|
|
|
+
|
|
|
|
|
+ // 在第一个选项写入现有段落
|
|
|
|
|
+ boolean isFirst = true;
|
|
|
|
|
+ for (String option : options) {
|
|
|
|
|
+ option = option.trim();
|
|
|
|
|
+ boolean isChecked = option.equals(selected.trim());
|
|
|
|
|
+
|
|
|
|
|
+ XWPFParagraph para;
|
|
|
|
|
+ if (isFirst) {
|
|
|
|
|
+ para = existingParagraph;
|
|
|
|
|
+ isFirst = false;
|
|
|
|
|
+ } else {
|
|
|
|
|
+ para = cell.addParagraph();
|
|
|
|
|
+ // 复制段落格式
|
|
|
|
|
+ if (existingParagraph.getCTP().getPPr() != null) {
|
|
|
|
|
+ para.getCTP().setPPr(existingParagraph.getCTP().getPPr());
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 创建 Wingdings checkbox run
|
|
|
|
|
+ XWPFRun checkRun = para.createRun();
|
|
|
|
|
+ setWingdingsFont(checkRun);
|
|
|
|
|
+ checkRun.setText(String.valueOf(isChecked ? WINGDINGS_CHECKED : WINGDINGS_UNCHECKED));
|
|
|
|
|
+
|
|
|
|
|
+ // 创建标签 run(普通字体)
|
|
|
|
|
+ XWPFRun labelRun = para.createRun();
|
|
|
|
|
+ labelRun.setText(" " + option);
|
|
|
|
|
+ // 复制字号等格式
|
|
|
|
|
+ if (existingParagraph.getRuns() != null && !existingParagraph.getRuns().isEmpty()) {
|
|
|
|
|
+ XWPFRun origRun = existingParagraph.getRuns().get(0);
|
|
|
|
|
+ if (origRun.getFontSizeAsDouble() != null) {
|
|
|
|
|
+ labelRun.setFontSize(origRun.getFontSizeAsDouble());
|
|
|
|
|
+ checkRun.setFontSize(origRun.getFontSizeAsDouble());
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 为 run 设置 Wingdings 字体。
|
|
|
|
|
+ */
|
|
|
|
|
+ private void setWingdingsFont(XWPFRun run) {
|
|
|
|
|
+ run.setFontFamily("Wingdings");
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 清空段落中所有 run 的文本。
|
|
|
|
|
+ */
|
|
|
|
|
+ private void clearParagraphText(XWPFParagraph paragraph) {
|
|
|
|
|
+ List<XWPFRun> runs = paragraph.getRuns();
|
|
|
|
|
+ if (runs != null) {
|
|
|
|
|
+ for (XWPFRun run : runs) {
|
|
|
|
|
+ setRunTextSafely(run, "");
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 替换段落中的普通 {{placeholder}} 占位符。
|
|
|
*/
|
|
*/
|
|
|
private void replacePlaceholdersInParagraph(XWPFParagraph paragraph, Map<String, String> placeholders) {
|
|
private void replacePlaceholdersInParagraph(XWPFParagraph paragraph, Map<String, String> placeholders) {
|
|
|
- // 首先尝试快速路径:拼接所有 run 文本,检查是否包含 {{
|
|
|
|
|
String fullText = paragraph.getText();
|
|
String fullText = paragraph.getText();
|
|
|
if (fullText == null || !fullText.contains("{{")) {
|
|
if (fullText == null || !fullText.contains("{{")) {
|
|
|
return;
|
|
return;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- // 收集所有 run 的文本
|
|
|
|
|
List<XWPFRun> runs = paragraph.getRuns();
|
|
List<XWPFRun> runs = paragraph.getRuns();
|
|
|
if (runs == null || runs.isEmpty()) {
|
|
if (runs == null || runs.isEmpty()) {
|
|
|
return;
|
|
return;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- // 构建完整文本和每个字符对应的 run 索引映射
|
|
|
|
|
StringBuilder sb = new StringBuilder();
|
|
StringBuilder sb = new StringBuilder();
|
|
|
- int[] charToRunIndex = new int[fullText.length() + 100]; // extra buffer
|
|
|
|
|
- int[] charToOffsetInRun = new int[fullText.length() + 100];
|
|
|
|
|
- int globalPos = 0;
|
|
|
|
|
-
|
|
|
|
|
- for (int ri = 0; ri < runs.size(); ri++) {
|
|
|
|
|
- String runText = runs.get(ri).getText(0);
|
|
|
|
|
- if (runText == null) {
|
|
|
|
|
- continue;
|
|
|
|
|
- }
|
|
|
|
|
- for (int ci = 0; ci < runText.length(); ci++) {
|
|
|
|
|
- if (globalPos < charToRunIndex.length) {
|
|
|
|
|
- charToRunIndex[globalPos] = ri;
|
|
|
|
|
- charToOffsetInRun[globalPos] = ci;
|
|
|
|
|
- }
|
|
|
|
|
- sb.append(runText.charAt(ci));
|
|
|
|
|
- globalPos++;
|
|
|
|
|
|
|
+ for (XWPFRun run : runs) {
|
|
|
|
|
+ String runText = run.getText(0);
|
|
|
|
|
+ if (runText != null) {
|
|
|
|
|
+ sb.append(runText);
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
String concatenated = sb.toString();
|
|
String concatenated = sb.toString();
|
|
|
Matcher matcher = PLACEHOLDER_PATTERN.matcher(concatenated);
|
|
Matcher matcher = PLACEHOLDER_PATTERN.matcher(concatenated);
|
|
|
-
|
|
|
|
|
if (!matcher.find()) {
|
|
if (!matcher.find()) {
|
|
|
return;
|
|
return;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- // 有占位符需要替换 — 使用 run 合并策略
|
|
|
|
|
- // 将所有文本合并到第一个 run,清空其余 run,然后执行替换
|
|
|
|
|
String replaced = PLACEHOLDER_PATTERN.matcher(concatenated).replaceAll(mr -> {
|
|
String replaced = PLACEHOLDER_PATTERN.matcher(concatenated).replaceAll(mr -> {
|
|
|
String key = mr.group(1).trim();
|
|
String key = mr.group(1).trim();
|
|
|
String value = placeholders.getOrDefault(key, " ");
|
|
String value = placeholders.getOrDefault(key, " ");
|
|
|
- // 如果值为空字符串,填一个空格保证格式
|
|
|
|
|
return value.isEmpty() ? " " : Matcher.quoteReplacement(value);
|
|
return value.isEmpty() ? " " : Matcher.quoteReplacement(value);
|
|
|
});
|
|
});
|
|
|
|
|
|
|
|
- // 检查替换后文本是否包含换行——如果有,需要分行处理
|
|
|
|
|
if (replaced.contains("\n")) {
|
|
if (replaced.contains("\n")) {
|
|
|
- // 对于包含换行的情况,将第一行放在当前 run,其余作为新 run
|
|
|
|
|
String[] lines = replaced.split("\n", -1);
|
|
String[] lines = replaced.split("\n", -1);
|
|
|
- // 设置第一个 run 的文本
|
|
|
|
|
- if (!runs.isEmpty()) {
|
|
|
|
|
- runs.get(0).setText(lines[0], 0);
|
|
|
|
|
- // 清空其余 run
|
|
|
|
|
- for (int i = 1; i < runs.size(); i++) {
|
|
|
|
|
- runs.get(i).setText("", 0);
|
|
|
|
|
- }
|
|
|
|
|
- // 添加剩余行作为额外文本行
|
|
|
|
|
- for (int i = 1; i < lines.length; i++) {
|
|
|
|
|
- runs.get(0).addBreak();
|
|
|
|
|
- runs.get(0).setText(lines[i]);
|
|
|
|
|
- }
|
|
|
|
|
|
|
+ setRunTextSafely(runs.get(0), lines[0]);
|
|
|
|
|
+ for (int i = 1; i < runs.size(); i++) {
|
|
|
|
|
+ setRunTextSafely(runs.get(i), "");
|
|
|
|
|
+ }
|
|
|
|
|
+ for (int i = 1; i < lines.length; i++) {
|
|
|
|
|
+ runs.get(0).addBreak();
|
|
|
|
|
+ runs.get(0).setText(lines[i]);
|
|
|
}
|
|
}
|
|
|
} else {
|
|
} else {
|
|
|
- // 简单情况:将替换后的完整文本设置到第一个 run
|
|
|
|
|
- if (!runs.isEmpty()) {
|
|
|
|
|
- runs.get(0).setText(replaced, 0);
|
|
|
|
|
- // 清空其余 run 的文本
|
|
|
|
|
- for (int i = 1; i < runs.size(); i++) {
|
|
|
|
|
- runs.get(i).setText("", 0);
|
|
|
|
|
|
|
+ setRunTextSafely(runs.get(0), replaced);
|
|
|
|
|
+ for (int i = 1; i < runs.size(); i++) {
|
|
|
|
|
+ setRunTextSafely(runs.get(i), "");
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private void setRunTextSafely(XWPFRun run, String text) {
|
|
|
|
|
+ if (run == null) {
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ try {
|
|
|
|
|
+ if (run.getCTR() != null && run.getCTR().sizeOfTArray() > 0) {
|
|
|
|
|
+ run.setText(text, 0);
|
|
|
|
|
+ } else {
|
|
|
|
|
+ run.setText(text);
|
|
|
|
|
+ }
|
|
|
|
|
+ } catch (Exception e) {
|
|
|
|
|
+ log.warn("设置段落文本失败,回退为空 run: {}", e.getMessage(), e);
|
|
|
|
|
+ try {
|
|
|
|
|
+ run.setText(text);
|
|
|
|
|
+ } catch (Exception ignored) {
|
|
|
|
|
+ // 忽略二次失败,避免中断整体流程
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ==================== LibreOffice PDF 转换 ====================
|
|
|
|
|
+
|
|
|
|
|
+ private byte[] convertToPdfWithLibreOffice(Path docxPath, Path outputDir) {
|
|
|
|
|
+ String sofficePath = findSoffice();
|
|
|
|
|
+ try {
|
|
|
|
|
+ ProcessBuilder pb = new ProcessBuilder(
|
|
|
|
|
+ sofficePath, "--headless", "--norestore",
|
|
|
|
|
+ "--convert-to", "pdf",
|
|
|
|
|
+ "--outdir", outputDir.toAbsolutePath().toString(),
|
|
|
|
|
+ docxPath.toAbsolutePath().toString()
|
|
|
|
|
+ );
|
|
|
|
|
+ pb.redirectErrorStream(true);
|
|
|
|
|
+ log.info("执行 LibreOffice 转 PDF: {}", String.join(" ", pb.command()));
|
|
|
|
|
+
|
|
|
|
|
+ Process process = pb.start();
|
|
|
|
|
+ String output;
|
|
|
|
|
+ try (BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream()))) {
|
|
|
|
|
+ output = reader.lines().reduce("", (a, b) -> a + "\n" + b);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ int exitCode = process.waitFor();
|
|
|
|
|
+ if (exitCode != 0) {
|
|
|
|
|
+ log.error("LibreOffice 转换失败, exitCode={}, output={}", exitCode, output);
|
|
|
|
|
+ throw new ServiceException("LibreOffice 转 PDF 失败 (exitCode=" + exitCode + ")");
|
|
|
|
|
+ }
|
|
|
|
|
+ log.info("LibreOffice 转换完成: {}", output.trim());
|
|
|
|
|
+
|
|
|
|
|
+ Path pdfPath = outputDir.resolve("report.pdf");
|
|
|
|
|
+ if (!Files.exists(pdfPath)) {
|
|
|
|
|
+ try (var stream = Files.list(outputDir)) {
|
|
|
|
|
+ pdfPath = stream.filter(p -> p.toString().endsWith(".pdf"))
|
|
|
|
|
+ .findFirst()
|
|
|
|
|
+ .orElseThrow(() -> new ServiceException("LibreOffice 转换后未找到 PDF 文件"));
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ byte[] pdfBytes = Files.readAllBytes(pdfPath);
|
|
|
|
|
+ log.info("生成 PDF 大小: {} bytes", pdfBytes.length);
|
|
|
|
|
+ return pdfBytes;
|
|
|
|
|
+ } catch (ServiceException e) {
|
|
|
|
|
+ throw e;
|
|
|
|
|
+ } catch (Exception e) {
|
|
|
|
|
+ throw new ServiceException("调用 LibreOffice 转 PDF 失败: " + e.getMessage());
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private String findSoffice() {
|
|
|
|
|
+ try {
|
|
|
|
|
+ Process which = new ProcessBuilder("where", "soffice").start();
|
|
|
|
|
+ try (BufferedReader reader = new BufferedReader(new InputStreamReader(which.getInputStream()))) {
|
|
|
|
|
+ String path = reader.readLine();
|
|
|
|
|
+ if (path != null && !path.isBlank() && new File(path.trim()).exists()) {
|
|
|
|
|
+ return path.trim();
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
+ } catch (Exception ignored) {}
|
|
|
|
|
+
|
|
|
|
|
+ for (String path : SOFFICE_PATHS) {
|
|
|
|
|
+ if (new File(path).exists()) {
|
|
|
|
|
+ return path;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ throw new ServiceException("未找到 LibreOffice,请安装后重试。");
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private void cleanupTempDir(Path tempDir) {
|
|
|
|
|
+ try {
|
|
|
|
|
+ try (var stream = Files.list(tempDir)) {
|
|
|
|
|
+ stream.forEach(p -> { try { Files.deleteIfExists(p); } catch (Exception ignored) {} });
|
|
|
|
|
+ }
|
|
|
|
|
+ Files.deleteIfExists(tempDir);
|
|
|
|
|
+ } catch (Exception e) {
|
|
|
|
|
+ log.warn("清理临时目录失败: {}", e.getMessage());
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private void saveDebugDocx(XWPFDocument document) {
|
|
|
|
|
+ try {
|
|
|
|
|
+ File debugFile = new File(DEBUG_DOCX_PATH).getAbsoluteFile();
|
|
|
|
|
+ try (FileOutputStream fos = new FileOutputStream(debugFile)) {
|
|
|
|
|
+ document.write(fos);
|
|
|
|
|
+ }
|
|
|
|
|
+ log.info("填充后的 Word 已保存到: {}", debugFile.getAbsolutePath());
|
|
|
|
|
+ } catch (Exception e) {
|
|
|
|
|
+ log.warn("保存调试 Word 文件失败: {}", e.getMessage());
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|