springboo3实现word转pdf

38 阅读7分钟

一、初期方案

说明:大部分AI工具都推荐这个方案,最终没有采用

1、架构 springboo3+docx4j(11.5.7)

引入依赖

<properties>
	<docx4j.version>11.5.7</docx4j.version>
</properties>


<dependency>
	<groupId>org.docx4j</groupId>
	<artifactId>docx4j-core</artifactId>
	<version>${docx4j.version}</version>
</dependency>
<dependency>
	<groupId>org.docx4j</groupId>
	<artifactId>docx4j-export-fo</artifactId>
	<version>${docx4j.version}</version>
</dependency>
<dependency>
	<groupId>org.docx4j</groupId>
	<artifactId>docx4j-JAXB-ReferenceImpl</artifactId>
	<version>${docx4j.version}</version>
</dependency>

控制层

package com.jiusi.cloud.pdf.controller;

import com.jiusi.cloud.aop.annotation.SysLog;
import com.jiusi.cloud.common.constant.ResponseEntity;
import com.jiusi.cloud.pdf.dto.FileInfoRequest;
import com.jiusi.cloud.pdf.dto.FileInfoResponse;
import com.jiusi.cloud.pdf.service.WordEditService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/word")
public class WordEditController {

    @Autowired
    WordEditService wordEditService;


    @PostMapping("/wordToPdf")
    @SysLog
    public ResponseEntity<FileInfoResponse> wordToPdfV2(@RequestBody FileInfoRequest fileInfoRequest) {
        return ResponseEntity.success(wordEditService.wordToPdf(fileInfoRequest));
    }
}

service层

import org.docx4j.Docx4J;
import org.docx4j.convert.out.FOSettings;
import org.docx4j.fonts.IdentityPlusMapper;
import org.docx4j.fonts.Mapper;
import org.docx4j.fonts.PhysicalFont;
import org.docx4j.fonts.PhysicalFonts;
import org.docx4j.openpackaging.packages.WordprocessingMLPackage;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;

import jakarta.annotation.PostConstruct;
import java.io.ByteArrayOutputStream;
import java.io.InputStream;

@Service
public class WordEditService{
    private static final Logger logger = LoggerFactory.getLogger(WordEditService.class);
    @PostConstruct
    public void init() {
        // 初始化字体发现
        try {
            // 设置字体目录(Windows 默认字体目录)
            String osName = System.getProperty("os.name").toLowerCase();
            if (osName.contains("win")) {
                System.setProperty("docx4j.fonts.dir", "C:\\Windows\\Fonts");
            } else {
                // Linux 或其他系统
                System.setProperty("docx4j.fonts.dir", "/usr/share/fonts");
            }
            
            // 发现系统字体
            PhysicalFonts.discoverPhysicalFonts();
            
            logger.info("字体初始化完成,已发现字体数量: {}", PhysicalFonts.getPhysicalFonts().size());
        } catch (Exception e) {
            logger.warn("字体初始化失败,将使用默认字体: {}", e.getMessage());
        }
    }

    /**
     * 将 Word 文档转换为 PDF
     * 
     * @param docxStream Word 文档输入流
     * @return PDF 字节数组
     * @throws Exception 转换异常
     */
    public byte[] convertToPdf(InputStream docxStream) throws Exception {
        try (docxStream) {
            // 加载 Word 文档
            WordprocessingMLPackage wordMLPackage = WordprocessingMLPackage.load(docxStream);

            // 配置字体映射,解决中文字体问题
            configureFontMapper(wordMLPackage);

            // 创建 FO 设置
            FOSettings foSettings = Docx4J.createFOSettings();
            foSettings.setOpcPackage(wordMLPackage);

            // 转换为 PDF
            ByteArrayOutputStream out = new ByteArrayOutputStream();
            Docx4J.toFO(foSettings, out, Docx4J.FLAG_EXPORT_PREFER_NONXSL);

            return out.toByteArray();
        }
    }

    /**
     * 配置字体映射,确保中文字符能正确显示,包括特殊符号
     */
    private void configureFontMapper(WordprocessingMLPackage wordMLPackage) {
        try {
            Mapper fontMapper = new IdentityPlusMapper();

            // 尝试获取中文字体(按优先级,优先选择支持更多 Unicode 字符的字体)
            String[] chineseFonts = {
                "Microsoft YaHei",  // 微软雅黑(支持更多 Unicode 符号,包括 ☑)
                "SimSun",           // 宋体
                "SimHei",           // 黑体
                "KaiTi",            // 楷体
                "FangSong",         // 仿宋
                "STSong",           // 华文宋体
                "STHeiti",          // 华文黑体
                "Noto Sans CJK SC", // Noto 字体
                "Source Han Sans SC" // 思源黑体
            };

            PhysicalFont chineseFont = null;
            for (String fontName : chineseFonts) {
                chineseFont = PhysicalFonts.get(fontName);
                if (chineseFont != null) {
                    logger.info("找到中文字体: {}", fontName);
                    break;
                }
            }

            // 查找支持更多 Unicode 符号的字体作为回退字体(用于特殊符号如 ☑)
            PhysicalFont symbolFont = null;
            String[] symbolFonts = {
                "Arial Unicode MS",      // 支持大量 Unicode 字符
                "Segoe UI Symbol",       // Windows 符号字体
                "Microsoft YaHei",       // 微软雅黑也支持很多符号
                "DejaVu Sans",           // Linux 常用
                "Noto Sans",             // Google Noto 字体
                "SimSun"                 // 最后回退到 SimSun
            };

            for (String fontName : symbolFonts) {
                PhysicalFont font = PhysicalFonts.get(fontName);
                if (font != null) {
                    symbolFont = font;
                    logger.info("找到符号字体(用于特殊字符回退): {}", fontName);
                    break;
                }
            }

            // 如果没有找到中文字体,使用符号字体
            if (chineseFont == null && symbolFont != null) {
                chineseFont = symbolFont;
                logger.warn("未找到中文字体,使用符号字体作为替代");
            }

            if (chineseFont != null) {
                // 使用支持更多字符的字体作为主字体
                // 如果找到了 Microsoft YaHei 或 Arial Unicode MS,优先使用它们(支持更多 Unicode 符号)
                PhysicalFont primaryFont = chineseFont;
                PhysicalFont fontForSimSun = chineseFont;
                
                // 如果主字体是 SimSun 且找到了支持更多符号的字体,将 SimSun 映射到符号字体
                // 这样可以解决 SimSun 中缺失的符号(如 ☑)问题
                if (symbolFont != null && !symbolFont.equals(chineseFont)) {
                    // 检查主字体是否是 SimSun
                    String primaryFontName = chineseFont.toString().toLowerCase();
                    if (primaryFontName.contains("simsun") || primaryFontName.contains("宋体")) {
                        // 将 SimSun 映射到支持更多字符的字体
                        fontForSimSun = symbolFont;
                        logger.info("配置字体回退:SimSun 缺失的符号(如 ☑)将使用符号字体: {}", 
                            symbolFont.toString());
                    }
                }

                // Times New Roman 所有变体映射(解决 TimesNewRomanPS-BoldMT 等问题)
                fontMapper.put("Times New Roman", primaryFont);
                fontMapper.put("TimesNewRomanPSMT", primaryFont);
                fontMapper.put("TimesNewRomanPS-BoldMT", primaryFont);
                fontMapper.put("TimesNewRomanPS-BoldItalicMT", primaryFont);
                fontMapper.put("TimesNewRomanPS-ItalicMT", primaryFont);
                fontMapper.put("TimesNewRomanPS-Bold", primaryFont);
                fontMapper.put("TimesNewRomanPS-Italic", primaryFont);
                fontMapper.put("TimesNewRomanPS-BoldItalic", primaryFont);
                fontMapper.put("TimesNewRoman", primaryFont);
                fontMapper.put("Times-Roman", primaryFont);
                fontMapper.put("Times-Bold", primaryFont);
                fontMapper.put("Times-Italic", primaryFont);
                fontMapper.put("Times-BoldItalic", primaryFont);
                
                // Arial 所有变体
                fontMapper.put("Arial", primaryFont);
                fontMapper.put("ArialMT", primaryFont);
                fontMapper.put("Arial-BoldMT", primaryFont);
                fontMapper.put("Arial-ItalicMT", primaryFont);
                fontMapper.put("Arial-BoldItalicMT", primaryFont);
                fontMapper.put("Arial-Bold", primaryFont);
                fontMapper.put("Arial-Italic", primaryFont);
                fontMapper.put("Arial-BoldItalic", primaryFont);
                
                // Calibri 所有变体
                fontMapper.put("Calibri", primaryFont);
                fontMapper.put("Calibri-Bold", primaryFont);
                fontMapper.put("Calibri-Italic", primaryFont);
                fontMapper.put("Calibri-BoldItalic", primaryFont);
                
                // 其他常见英文字体
                fontMapper.put("Helvetica", primaryFont);
                fontMapper.put("Helvetica-Bold", primaryFont);
                fontMapper.put("Helvetica-Italic", primaryFont);
                fontMapper.put("Helvetica-BoldItalic", primaryFont);
                fontMapper.put("Courier", primaryFont);
                fontMapper.put("Courier-Bold", primaryFont);
                fontMapper.put("Courier-Italic", primaryFont);
                fontMapper.put("Courier-BoldItalic", primaryFont);
                
                // 中文字体映射 - 如果 SimSun 不支持某些符号,映射到支持更多字符的字体
                fontMapper.put("宋体", fontForSimSun);
                fontMapper.put("SimSun", fontForSimSun);
                fontMapper.put("黑体", primaryFont);
                fontMapper.put("SimHei", primaryFont);
                fontMapper.put("微软雅黑", primaryFont);
                fontMapper.put("Microsoft YaHei", primaryFont);
                fontMapper.put("仿宋_GB2312", primaryFont);
                fontMapper.put("FangSong", primaryFont);
                fontMapper.put("楷体", primaryFont);
                fontMapper.put("KaiTi", primaryFont);
                
                logger.info("字体映射配置完成,已映射 Times New Roman、Arial、Calibri 等字体变体到中文字体");
                if (symbolFont != null) {
                    logger.info("特殊符号(如 ☑)将使用支持更多 Unicode 字符的字体");
                }
            } else {
                logger.warn("未找到中文字体,可能导致中文显示异常");
            }

            wordMLPackage.setFontMapper(fontMapper);
        } catch (Exception e) {
            logger.error("配置字体映射失败: {}", e.getMessage(), e);
        }
    }

}

2、存在问题

日志报错:

- Glyph "晋" (0x664b) not available in font "TimesNewRomanPS-BoldMT"。

- Glyph "☑" (0x2611) not available in font "SimSun".

转化后的文档中中文字符全部为乱码。

分析没有读到中文字体、特殊符号转化错误。

3、解决问题

3.1、打印构造器的 PhysicalFonts.discoverPhysicalFonts()内容,可以输出"C:\\Windows\\Fonts"下的所有字体;

segoe ui black=org.docx4j.fonts.PhysicalFont@5bdb9c69
constantia=org.docx4j.fonts.PhysicalFont@511de4dd
bahnschrift=org.docx4j.fonts.PhysicalFont@37e64e37
corbel=org.docx4j.fonts.PhysicalFont@31204303
microsoft phagspa bold=org.docx4j.fonts.PhysicalFont@369fb99a
trebuchet ms bold italic=org.docx4j.fonts.PhysicalFont@81a8898
georgia bold=org.docx4j.fonts.PhysicalFont@d08f85a
sitka banner bold italic=org.docx4j.fonts.PhysicalFont@3e839aa3
courier new italic=org.docx4j.fonts.PhysicalFont@751bb780
...
...

3.2、详细指定字体

 /**
     * 配置字体映射,确保中文字符能正确显示,包括特殊符号
     */
    private static void configureFontMapper(WordprocessingMLPackage wordMLPackage) {
        try {
            Mapper fontMapper = new IdentityPlusMapper();

            //微软雅黑 microsoft yahei
            PhysicalFont microsoftYaHeiFont = PhysicalFonts.get("Microsoft YaHei");
            if (!Objects.isNull(microsoftYaHeiFont)) {
                logger.debug("Microsoft YaHei");
                fontMapper.put("微软雅黑", microsoftYaHeiFont);
                fontMapper.put("Microsoft YaHei", microsoftYaHeiFont);
            } else {
                logger.debug("msyh");
                microsoftYaHeiFont = PhysicalFonts.get("msyh");
                fontMapper.put("微软雅黑", microsoftYaHeiFont);
                fontMapper.put("Microsoft YaHei", microsoftYaHeiFont);
            }

            //宋体 simsun  大写还是小写???
            PhysicalFont simSunFont = PhysicalFonts.get("SimSun");
            if (!Objects.isNull(simSunFont)) {
                fontMapper.put("SimSun", simSunFont);
                fontMapper.put("宋体", simSunFont);
            }

            //仿宋  fangsong 大写还是小写???
            PhysicalFont fangSongGB2312Font = PhysicalFonts.get("FangSong");
            if (Objects.isNull(fangSongGB2312Font)) {
                fangSongGB2312Font = simSunFont;
            }
            fontMapper.put("FangSong_GB2312", fangSongGB2312Font);
            fontMapper.put("仿宋_GB2312", fangSongGB2312Font);
            fontMapper.put("仿宋", fangSongGB2312Font);

            //黑体 simhei 大写还是小写???
            PhysicalFont simHeiFont = PhysicalFonts.get("SimHei");
            if (!Objects.isNull(simHeiFont)) {
                fontMapper.put("黑体", simHeiFont);
            } else {
                logger.info("黑体 is null");
            }


            //calibri 大写还是小写???
            PhysicalFont calibriFont = PhysicalFonts.get("Calibri");
            if (!Objects.isNull(simSunFont)) {
                fontMapper.put("Calibri", calibriFont);
                fontMapper.put("Calibri", calibriFont);
                fontMapper.put("Calibri-Bold", calibriFont);
                fontMapper.put("Calibri-Italic", calibriFont);
                fontMapper.put("Calibri-BoldItalic", calibriFont);
            } else {
                logger.info("Calibri is null");
            }


            //泰晤士新罗马 times new roman
            PhysicalFont timesNewRomanFont = PhysicalFonts.get("Times New Roman");
            if (!Objects.isNull(timesNewRomanFont)) {
                // Times New Roman 所有变体映射(解决 TimesNewRomanPS-BoldMT 等问题)
                fontMapper.put("Times New Roman", timesNewRomanFont);
                fontMapper.put("TimesNewRomanPSMT", timesNewRomanFont);
                fontMapper.put("TimesNewRomanPS-BoldMT", timesNewRomanFont);
                fontMapper.put("TimesNewRomanPS-BoldItalicMT", timesNewRomanFont);
                fontMapper.put("TimesNewRomanPS-ItalicMT", timesNewRomanFont);
                fontMapper.put("TimesNewRomanPS-Bold", timesNewRomanFont);
                fontMapper.put("TimesNewRomanPS-Italic", timesNewRomanFont);
                fontMapper.put("TimesNewRomanPS-BoldItalic", timesNewRomanFont);
                fontMapper.put("TimesNewRoman", timesNewRomanFont);
                fontMapper.put("Times-Roman", timesNewRomanFont);
                fontMapper.put("Times-Bold", timesNewRomanFont);
                fontMapper.put("Times-Italic", timesNewRomanFont);
                fontMapper.put("Times-BoldItalic", timesNewRomanFont);
            } else {
                logger.info("Times New Roman is null");
            }

            //symbol
            PhysicalFont symbolsFont = PhysicalFonts.get("symbol");
            if (!Objects.isNull(symbolsFont)) {
                fontMapper.put("symbol", symbolsFont);
            } else {
                logger.info("symbol is null");
            }

            //复选框等特殊符号 seguiemj
            PhysicalFont seguiemjFont = PhysicalFonts.get("segoe ui emoji");
            if (!Objects.isNull(seguiemjFont)) {
                fontMapper.put("☑", seguiemjFont);
                fontMapper.put("☐", seguiemjFont);  // 方框
                fontMapper.put("✓", seguiemjFont);  // 对勾
                fontMapper.put("✔", seguiemjFont);  // 粗对勾

                fontMapper.put("SymbolsFallback", seguiemjFont);
            } else {
                logger.info("seguiemj is null");
            }

            wordMLPackage.setFontMapper(fontMapper);
        } catch (Exception e) {
            logger.error("配置字体映射失败: {}", e.getMessage(), e);
        }
    }

4、最终结果

中文字体“宋体”、“仿宋”、“微软雅黑”、“黑体”等常见中文字体都没问题。

但是复选框"☑"等特殊符号依然不能解决;

此时已经解决了2天,只能更换其他方案

二、最终方案

1、架构:springboo3+LibreOffice

windows操作系统

下载 LibreOffice | LibreOffice 简体中文官方网站 - 自由免费的办公套件

linux操作系统:

sudo yum install libreoffice

检查是否生效:

soffice --version

> soffice --version
LibreOffice 25.2.7.2 5cbfd1ab6520636bb5f7b99185aa69bd7456825d

java代码

LibreOfficeService.java

import com.jiusi.cloud.common.utils.FileUtils;
import org.apache.commons.lang3.SystemUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;

import java.io.*;
import java.nio.file.Files;
import java.nio.file.Paths;
import java.nio.file.StandardCopyOption;
import java.util.Arrays;

@Service
public class LibreOfficeService {

    private static final Logger logger = LoggerFactory.getLogger(LibreOfficeService.class);
    // PDF所有者密码(用于管理权限,可自定义)
    private static final String PDF_OWNER_PWD = "123456";
    /**
     * 将文件转换为PDF
     */
    public boolean convertToPdf(File inputFile, File outputFile) throws IOException, InterruptedException {
        logger.info("开始转换文件: {} -> {}", inputFile.getName(), outputFile.getName());

        String command = getLibreOfficePath();
        // 第一步:构建LibreOffice命令行参数
        String[] libreOfficeCmd = new String[]{
                command,
                "--headless",        // 无界面运行
                "--invisible",       // 额外隐藏所有窗口(补充 --headless 不足)
                "--convert-to", "pdf",
                "--outdir", FileUtils.LOCAL_BASIC_OUT_PATH,
                "--norestore",        // 不恢复上次打开的文档
                "--nologo",           // 不显示启动logo
                "--nolockcheck",      // 禁用文件锁定检查
                "--nodefault",        // 不打开默认文档
                inputFile.getAbsolutePath()
        };
        logger.info(Arrays.toString(libreOfficeCmd));

        Process libreProcess = new ProcessBuilder(libreOfficeCmd).redirectErrorStream(true) // 将错误流重定向到输出流,方便调试
                .start();

        // 等待命令执行完成
        int libreExitCode = libreProcess.waitFor();
        //转化成功
        if (libreExitCode == 0) {
            // 检查输出文件是否生成
            String expectedOutputFile = getExpectedOutputPath(inputFile, FileUtils.LOCAL_BASIC_OUT_PATH);
            File expectedFile = new File(expectedOutputFile);

            if (expectedFile.exists()) {
                // 重命名到目标文件
                Files.move(expectedFile.toPath(), outputFile.toPath(),
                        StandardCopyOption.REPLACE_EXISTING);
                logger.info("转换成功: {} -> {}", inputFile.getName(), outputFile.getName());
                return true;
            }
            return false;
        }
        return false;
    }

    /**
     * 获取LibreOffice可执行文件路径
     */
    private String getLibreOfficePath() {
        // 自动检测系统路径
        if (SystemUtils.IS_OS_WINDOWS) {
            // Windows系统
            String possiblePaths = "E:\\Program Files\\LibreOffice\\program\\soffice.exe";

            if (new File(possiblePaths).exists()) {
                return "\"" + possiblePaths + "\"";
            }
            return "soffice.exe";

        } else {
            // Linux/Unix系统
            return "soffice";
        }
    }

    /**
     * 获取预期的输出文件路径
     */
    private String getExpectedOutputPath(File inputFile, String outputDir) {
        String fileName = inputFile.getName();
        int dotIndex = fileName.lastIndexOf('.');
        String baseName = (dotIndex == -1) ? fileName : fileName.substring(0, dotIndex);

        return Paths.get(outputDir, baseName + ".pdf").toString();
    }

}

WordEditServiceImpl.java

import com.jiusi.cloud.common.constant.ErrorEnum;
import com.jiusi.cloud.common.exception.BusinessException;
import com.jiusi.cloud.common.utils.FileUtils;
import com.jiusi.cloud.common.utils.OrderNoGenerator;
import com.jiusi.cloud.cos.utils.CosUtil;
import com.jiusi.cloud.pdf.dto.FileInfoRequest;
import com.jiusi.cloud.pdf.dto.FileInfoResponse;
import com.jiusi.cloud.pdf.service.WordEditService;
import com.jiusi.cloud.pdf.utils.FileCheckUtil;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.util.StringUtils;

import java.io.File;

@Service
public class WordEditServiceImpl implements WordEditService {

    private final Logger logger = LoggerFactory.getLogger(WordEditServiceImpl.class);

    @Autowired
    CosUtil cosUtil;

    @Autowired
    LibreOfficeService libreOfficeService;

    @Override
    public FileInfoResponse wordToPdfV2(FileInfoRequest fileInfoRequest) {
        //第一步:校验文件是否存在
        File file = FileCheckUtil.checkFile(fileInfoRequest, FileUtils.SUFFIX_WORD);

        //第二步:生成文件名(没有后缀)
        String outFilePdf = "";
        if (StringUtils.hasText(fileInfoRequest.getOrderNo())) {
            outFilePdf = fileInfoRequest.getOrderNo();
        } else {
            outFilePdf = OrderNoGenerator.generaterOrderNo();
        }
        //文件名称(路径+文件名+后缀)
        String fullOutFilePdf = FileUtils.LOCAL_BASIC_OUT_PATH + File.separator + outFilePdf + FileUtils.SUFFIX_PDF;
        //转化后的pdf文档
        File outPdfFile = new File(fullOutFilePdf);

        // 执行转换
        try {
            boolean result = libreOfficeService.convertToPdf(file, outPdfFile);
            if (result) {
                //第三步:上传cos服务器
                String cosUrl = cosUtil.upload(outFilePdf + FileUtils.SUFFIX_PDF, new File(fullOutFilePdf));

                FileInfoResponse response = new FileInfoResponse();
                response.setFileUrl(cosUrl);
                response.setFileName(outFilePdf + FileUtils.SUFFIX_PDF);
                //TODO 第四步:将图片的cos url保存到数据库(待补充)
                //第五步:删除临时文件
                FileUtils.deleteTempFile(new File(fullOutFilePdf));
                return response;
            } else {
                throw new BusinessException(ErrorEnum.PDF_TO_WORD_ERROR);
            }
        } catch (Exception e) {
            logger.error(e.getMessage(), e);
            throw new BusinessException(ErrorEnum.PDF_TO_WORD_ERROR);
        }
    }
}

2、存在问题 

通过命令行执行过程中可能存在连接打印机的情况。可以禁用打印机;

3、最终可以完成转化

以下是完成后的展示结果,感兴趣的兄弟可以登录查看