一、初期方案
说明:大部分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、最终可以完成转化
以下是完成后的展示结果,感兴趣的兄弟可以登录查看