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