diff --git a/pom.xml b/pom.xml
index a9c55777..1e05723b 100644
--- a/pom.xml
+++ b/pom.xml
@@ -60,6 +60,8 @@
1.5.4
1.12.2
+
+ 2.0.4
3.14.0
@@ -373,6 +375,23 @@
${warm-flow.version}
+
+
+ com.deepoove
+ poi-tl
+ ${poi-tl.version}
+
+
+ fr.opensagres.xdocreport
+ fr.opensagres.poi.xwpf.converter.pdf
+ ${xdocreport.poi.version}
+
+
+ fr.opensagres.xdocreport
+ fr.opensagres.xdocreport.itext.extension
+ ${xdocreport.poi.version}
+
+
diff --git a/ruoyi-common/hwbm-common-workflow/pom.xml b/ruoyi-common/hwbm-common-workflow/pom.xml
index f4d3818d..d751671c 100644
--- a/ruoyi-common/hwbm-common-workflow/pom.xml
+++ b/ruoyi-common/hwbm-common-workflow/pom.xml
@@ -6,7 +6,7 @@
org.dromara
ruoyi-common
- 2.5.0
+ ${revision}
hwbm-common-workflow
diff --git a/ruoyi-common/ruoyi-common-word/pom.xml b/ruoyi-common/ruoyi-common-word/pom.xml
index b93c927b..2cec2c22 100644
--- a/ruoyi-common/ruoyi-common-word/pom.xml
+++ b/ruoyi-common/ruoyi-common-word/pom.xml
@@ -19,14 +19,21 @@
com.deepoove
poi-tl
- ${poi-tl.version}
-
+
org.dromara
ruoyi-common-core
- ${revision}
+
+
+
+ fr.opensagres.xdocreport
+ fr.opensagres.poi.xwpf.converter.pdf
+
+
+ fr.opensagres.xdocreport
+ fr.opensagres.xdocreport.itext.extension
diff --git a/ruoyi-common/ruoyi-common-word/src/main/java/org/dromara/common/word/pdf/WordPdfOptionsFactory.java b/ruoyi-common/ruoyi-common-word/src/main/java/org/dromara/common/word/pdf/WordPdfOptionsFactory.java
new file mode 100644
index 00000000..c5ccfabb
--- /dev/null
+++ b/ruoyi-common/ruoyi-common-word/src/main/java/org/dromara/common/word/pdf/WordPdfOptionsFactory.java
@@ -0,0 +1,131 @@
+package org.dromara.common.word.pdf;
+
+import com.lowagie.text.Font;
+import com.lowagie.text.pdf.BaseFont;
+import fr.opensagres.poi.xwpf.converter.pdf.PdfOptions;
+import fr.opensagres.xdocreport.itext.extension.font.IFontProvider;
+import fr.opensagres.xdocreport.itext.extension.font.ITextFontRegistry;
+import lombok.AccessLevel;
+import lombok.NoArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+
+import java.awt.Color;
+import java.io.InputStream;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+
+/**
+ * Word 转 PDF 选项工厂(中文字体 + 编码)
+ *
+ * 未配置中文字体时,PDF 常出现汉字宽度计算错误,导致文字与表格线重叠。
+ */
+@Slf4j
+@NoArgsConstructor(access = AccessLevel.PRIVATE)
+public final class WordPdfOptionsFactory {
+
+ private static final String FONT_CACHE_KEY = "zh-cn";
+
+ private static final Map BASE_FONT_CACHE = new ConcurrentHashMap<>();
+
+ /**
+ * 创建适用于中文文档的 PDF 转换选项
+ */
+ public static PdfOptions createChinesePdfOptions() {
+ PdfOptions options = PdfOptions.create();
+ options.fontEncoding(BaseFont.IDENTITY_H);
+ options.fontProvider(chineseFontProvider());
+ return options;
+ }
+
+ private static IFontProvider chineseFontProvider() {
+ return (familyName, encoding, size, style, color) -> {
+ try {
+ BaseFont baseFont = resolveChineseBaseFont();
+ Font font = new Font(baseFont, size, style, color == null ? Color.BLACK : color);
+ if (familyName != null) {
+ font.setFamily(familyName);
+ }
+ return font;
+ } catch (Exception e) {
+ log.warn("加载中文字体失败,使用 PDF 默认字体, familyName={}", familyName, e);
+ return ITextFontRegistry.getRegistry().getFont(familyName, encoding, size, style, color);
+ }
+ };
+ }
+
+ private static BaseFont resolveChineseBaseFont() throws Exception {
+ BaseFont cached = BASE_FONT_CACHE.get(FONT_CACHE_KEY);
+ if (cached != null) {
+ return cached;
+ }
+ synchronized (WordPdfOptionsFactory.class) {
+ cached = BASE_FONT_CACHE.get(FONT_CACHE_KEY);
+ if (cached != null) {
+ return cached;
+ }
+ BaseFont loaded = loadChineseBaseFont();
+ BASE_FONT_CACHE.put(FONT_CACHE_KEY, loaded);
+ log.info("PDF 导出已加载中文字体: {}", loaded.getPostscriptFontName());
+ return loaded;
+ }
+ }
+
+ private static BaseFont loadChineseBaseFont() throws Exception {
+ // 1) classpath 内置字体(fonts/simsun.ttc、msyh.ttc 或 .ttf)
+ BaseFont classpathFont = loadClasspathFont("fonts/simsun.ttf", "simsun.ttf");
+ if (classpathFont != null) {
+ return classpathFont;
+ }
+ classpathFont = loadClasspathFont("fonts/simsun.ttc", "simsun.ttc,0");
+ if (classpathFont != null) {
+ return classpathFont;
+ }
+ classpathFont = loadClasspathFont("fonts/msyh.ttf", "msyh.ttf");
+ if (classpathFont != null) {
+ return classpathFont;
+ }
+ classpathFont = loadClasspathFont("fonts/msyh.ttc", "msyh.ttc,0");
+ if (classpathFont != null) {
+ return classpathFont;
+ }
+
+ // 2) 操作系统字体(开发机 Windows / Linux 常见路径)
+ List candidates = new ArrayList<>();
+ String os = System.getProperty("os.name", "").toLowerCase();
+ if (os.contains("win")) {
+ candidates.add("C:/Windows/Fonts/simsun.ttc,0");
+ candidates.add("C:/Windows/Fonts/msyh.ttc,0");
+ candidates.add("C:/Windows/Fonts/simhei.ttf");
+ candidates.add("C:/Windows/Fonts/arialuni.ttf");
+ } else {
+ candidates.add("/usr/share/fonts/truetype/wqy/wqy-microhei.ttc,0");
+ candidates.add("/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc,0");
+ candidates.add("/usr/share/fonts/truetype/arphic/uming.ttc,0");
+ }
+
+ for (String fontPath : candidates) {
+ String filePart = fontPath.contains(",") ? fontPath.substring(0, fontPath.indexOf(',')) : fontPath;
+ if (Files.isRegularFile(Path.of(filePart))) {
+ return BaseFont.createFont(fontPath, BaseFont.IDENTITY_H, BaseFont.EMBEDDED);
+ }
+ }
+
+ throw new IllegalStateException(
+ "未找到可用的中文字体,请在 ruoyi-common-word/src/main/resources/fonts/ 下放置 simsun.ttc/msyh.ttc(或 .ttf),"
+ + "或在服务器安装中文字体");
+ }
+
+ private static BaseFont loadClasspathFont(String resourcePath, String fontNameForCreate) throws Exception {
+ try (InputStream is = Thread.currentThread().getContextClassLoader().getResourceAsStream(resourcePath)) {
+ if (is == null) {
+ return null;
+ }
+ return BaseFont.createFont(fontNameForCreate, BaseFont.IDENTITY_H, BaseFont.EMBEDDED, true,
+ is.readAllBytes(), null);
+ }
+ }
+}
diff --git a/ruoyi-common/ruoyi-common-word/src/main/java/org/dromara/common/word/util/WordTemplateUtil.java b/ruoyi-common/ruoyi-common-word/src/main/java/org/dromara/common/word/util/WordTemplateUtil.java
index 435a726e..54d7fbf5 100644
--- a/ruoyi-common/ruoyi-common-word/src/main/java/org/dromara/common/word/util/WordTemplateUtil.java
+++ b/ruoyi-common/ruoyi-common-word/src/main/java/org/dromara/common/word/util/WordTemplateUtil.java
@@ -2,6 +2,9 @@ package org.dromara.common.word.util;
import com.deepoove.poi.XWPFTemplate;
import com.deepoove.poi.config.Configure;
+import fr.opensagres.poi.xwpf.converter.pdf.PdfConverter;
+import fr.opensagres.poi.xwpf.converter.pdf.PdfOptions;
+import org.dromara.common.word.pdf.WordPdfOptionsFactory;
import jakarta.servlet.http.HttpServletResponse;
import lombok.AccessLevel;
import lombok.NoArgsConstructor;
@@ -226,6 +229,53 @@ public final class WordTemplateUtil {
renderToOutputStream(templatePath, data, bos);
return bos.toByteArray();
}
+
+ /**
+ * 渲染模板并输出 PDF 到 HttpServletResponse(先渲染 Word 再转换)
+ *
+ * @param templatePath classpath 模板路径
+ * @param fileName 下载文件名(不含后缀,自动补全 .pdf)
+ * @param data 模板数据
+ * @param response HttpServletResponse
+ */
+ public static void renderToPdfResponse(String templatePath, String fileName, Map data,
+ HttpServletResponse response) {
+ renderToPdfResponse(templatePath, fileName, data, null, response);
+ }
+
+ /**
+ * 渲染模板并输出 PDF 到 HttpServletResponse(支持自定义 Configure)
+ */
+ public static void renderToPdfResponse(String templatePath, String fileName, Map data,
+ Configure config, HttpServletResponse response) {
+ Objects.requireNonNull(response, "HttpServletResponse must not be null");
+ if (StringUtils.isBlank(templatePath)) {
+ throw new ServiceException("Word 模板路径不能为空");
+ }
+ String safeFileName = StringUtils.isBlank(fileName) ? "export" : fileName.trim();
+ Map safeData = (data == null ? Collections.emptyMap() : data);
+
+ log.info("Word模板导出PDF开始, templatePath={}, fileName={}", templatePath, safeFileName);
+ try (XWPFTemplate template = config == null
+ ? buildTemplate(templatePath, safeData)
+ : buildTemplate(templatePath, safeData, config);
+ OutputStream os = resetPdfResponse(safeFileName, response)) {
+ PdfOptions options = WordPdfOptionsFactory.createChinesePdfOptions();
+ PdfConverter.getInstance().convert(template.getXWPFDocument(), os, options);
+ os.flush();
+ log.info("Word模板导出PDF成功, fileName={}", safeFileName);
+ } catch (Exception e) {
+ log.error("Word 模板导出 PDF 失败, templatePath={}, fileName={}", templatePath, safeFileName, e);
+ throw new ServiceException("Word 模板导出 PDF 失败,请联系管理员").setDetailMessage(e.getMessage());
+ }
+ }
+
+ private static OutputStream resetPdfResponse(String fileName, HttpServletResponse response) throws IOException {
+ String realName = fileName.endsWith(".pdf") ? fileName : fileName + ".pdf";
+ FileUtils.setAttachmentResponseHeader(response, realName);
+ response.setContentType("application/pdf");
+ return response.getOutputStream();
+ }
// =====================================================================
// 典型调用示例(仅作为文档说明,不参与编译):
diff --git a/ruoyi-common/ruoyi-common-word/src/main/resources/fonts/msyh.ttc b/ruoyi-common/ruoyi-common-word/src/main/resources/fonts/msyh.ttc
new file mode 100644
index 00000000..37c28de0
Binary files /dev/null and b/ruoyi-common/ruoyi-common-word/src/main/resources/fonts/msyh.ttc differ
diff --git a/ruoyi-common/ruoyi-common-word/src/main/resources/fonts/simsun.ttc b/ruoyi-common/ruoyi-common-word/src/main/resources/fonts/simsun.ttc
new file mode 100644
index 00000000..5f22ce3f
Binary files /dev/null and b/ruoyi-common/ruoyi-common-word/src/main/resources/fonts/simsun.ttc differ
diff --git a/ruoyi-modules/ruoyi-oa/src/main/resources/采购审批单模板.docx b/ruoyi-modules/ruoyi-oa/src/main/resources/采购审批单模板.docx
new file mode 100644
index 00000000..137ecf87
Binary files /dev/null and b/ruoyi-modules/ruoyi-oa/src/main/resources/采购审批单模板.docx differ