| | |
| | | package com.estate.expert.admin.utils.wordtopdf; |
| | | |
| | | import lombok.extern.slf4j.Slf4j; |
| | | import org.apache.poi.xwpf.usermodel.IBodyElement; |
| | | import org.apache.poi.xwpf.usermodel.XWPFDocument; |
| | | import org.apache.poi.xwpf.usermodel.XWPFFooter; |
| | | import org.apache.poi.xwpf.usermodel.XWPFHeader; |
| | | import org.apache.poi.xwpf.usermodel.XWPFParagraph; |
| | | import org.apache.poi.xwpf.usermodel.XWPFRun; |
| | | import org.apache.poi.xwpf.usermodel.XWPFTable; |
| | | import org.apache.poi.xwpf.usermodel.XWPFTableCell; |
| | | import org.apache.poi.xwpf.usermodel.XWPFTableRow; |
| | | import org.jodconverter.core.office.OfficeException; |
| | | import org.jodconverter.core.office.OfficeManager; |
| | | import org.jodconverter.local.LocalConverter; |
| | |
| | | |
| | | import javax.annotation.PreDestroy; |
| | | import java.io.File; |
| | | import java.io.FileInputStream; |
| | | import java.io.IOException; |
| | | import java.io.InputStream; |
| | | import java.io.OutputStream; |
| | | import java.nio.file.Files; |
| | | import java.nio.file.Path; |
| | | import java.util.Arrays; |
| | | import java.util.LinkedHashSet; |
| | | import java.util.List; |
| | | import java.util.Locale; |
| | | import java.util.Set; |
| | | import java.util.concurrent.atomic.AtomicInteger; |
| | | |
| | | /** |
| | | * Word 转 PDF 工具类(使用 LibreOffice/OpenOffice) |
| | | * 支持图表、图片、表格等复杂格式 |
| | | */ |
| | | @Slf4j |
| | | public class WordToPdfConverter { |
| | | |
| | | private OfficeManager officeManager; |
| | | private static WordToPdfConverter instance; |
| | | private final AtomicInteger conversionCount = new AtomicInteger(0); |
| | | |
| | | // LibreOffice 安装路径(根据您的安装位置调整) |
| | | private static final String LIBRE_OFFICE_PATH = "C:\\Program Files\\LibreOffice"; |
| | | //private static final String LIBRE_OFFICE_PATH = "C:\\Program Files (x86)\\LibreOffice"; |
| | | |
| | | // 支持转换的文件格式 |
| | | private static final String LIBRE_OFFICE_PATH = "/usr/lib/libreoffice"; |
| | | private static final String DEFAULT_FALLBACK_FONT = "Noto Sans CJK SC"; |
| | | private static final Set<String> WINDOWS_CJK_FONTS = new LinkedHashSet<>(Arrays.asList( |
| | | "\u5b8b\u4f53", "SimSun", |
| | | "\u5fae\u8f6f\u96c5\u9ed1", "Microsoft YaHei", |
| | | "\u9ed1\u4f53", "SimHei", |
| | | "\u4eff\u5b8b", "FangSong", |
| | | "\u6977\u4f53", "KaiTi", |
| | | "\u65b0\u5b8b\u4f53", "NSimSun", |
| | | "\u7b49\u7ebf", "DengXian" |
| | | )); |
| | | private static final List<String> SUPPORTED_EXTENSIONS = Arrays.asList( |
| | | ".doc", ".docx", ".rtf", ".txt", ".odt" |
| | | ); |
| | | |
| | | private static WordToPdfConverter instance; |
| | | |
| | | private final AtomicInteger conversionCount = new AtomicInteger(0); |
| | | private OfficeManager officeManager; |
| | | |
| | | private WordToPdfConverter() { |
| | | initializeOfficeManager(); |
| | | } |
| | | |
| | | /** |
| | | * 获取单例实例 |
| | | */ |
| | | public static synchronized WordToPdfConverter getInstance() { |
| | | if (instance == null) { |
| | | instance = new WordToPdfConverter(); |
| | |
| | | return instance; |
| | | } |
| | | |
| | | /** |
| | | * 初始化 Office 管理器 |
| | | */ |
| | | private void initializeOfficeManager() { |
| | | try { |
| | | log.info("正在初始化 LibreOffice 管理器..."); |
| | | log.info("Initializing LibreOffice manager"); |
| | | |
| | | // 检查 LibreOffice 是否安装 |
| | | File officeHome = new File(LIBRE_OFFICE_PATH); |
| | | if (!officeHome.exists() || !officeHome.isDirectory()) { |
| | | throw new RuntimeException("未找到 LibreOffice 安装目录: " + LIBRE_OFFICE_PATH + |
| | | "\n请安装 LibreOffice 或修改 LIBRE_OFFICE_PATH 配置"); |
| | | throw new RuntimeException("LibreOffice home not found: " + LIBRE_OFFICE_PATH); |
| | | } |
| | | |
| | | // 创建 Office 管理器 |
| | | officeManager = LocalOfficeManager.builder() |
| | | .officeHome(officeHome) |
| | | .portNumbers(2002) // 使用 2002 端口 |
| | | .processTimeout(300000L) // 5分钟超时 |
| | | .taskExecutionTimeout(300000L) // 5分钟任务超时 |
| | | .maxTasksPerProcess(20) // 每个进程最大任务数 |
| | | .install() // 如果未安装则尝试安装 |
| | | .portNumbers(2002) |
| | | .processTimeout(300000L) |
| | | .taskExecutionTimeout(300000L) |
| | | .maxTasksPerProcess(20) |
| | | .install() |
| | | .build(); |
| | | |
| | | // 启动服务 |
| | | officeManager.start(); |
| | | log.info("LibreOffice 管理器启动成功,服务端口: 2002"); |
| | | |
| | | log.info("LibreOffice manager started on port 2002"); |
| | | } catch (Exception e) { |
| | | log.error("初始化 LibreOffice 管理器失败", e); |
| | | throw new RuntimeException("无法启动 LibreOffice 服务: " + e.getMessage(), e); |
| | | log.error("Failed to initialize LibreOffice manager", e); |
| | | throw new RuntimeException("Unable to start LibreOffice service: " + e.getMessage(), e); |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * 转换 Word 文件为 PDF |
| | | * @param wordFilePath Word 文件路径 |
| | | * @param pdfFilePath PDF 输出路径 |
| | | * @return 是否转换成功 |
| | | */ |
| | | public boolean convert(String wordFilePath, String pdfFilePath) { |
| | | return convert(new File(wordFilePath), new File(pdfFilePath)); |
| | | } |
| | | |
| | | /** |
| | | * 转换 Word 文件为 PDF |
| | | * @param wordFile Word 文件 |
| | | * @param pdfFile PDF 输出文件 |
| | | * @return 是否转换成功 |
| | | */ |
| | | public boolean convert(File wordFile, File pdfFile) { |
| | | long startTime = System.currentTimeMillis(); |
| | | int currentCount = conversionCount.incrementAndGet(); |
| | | File conversionSource = wordFile; |
| | | |
| | | try { |
| | | // 验证输入文件 |
| | | validateInputFile(wordFile); |
| | | |
| | | // 准备输出目录 |
| | | prepareOutputDirectory(pdfFile); |
| | | conversionSource = prepareConversionSource(wordFile); |
| | | |
| | | log.info("开始转换 [{}]: {} -> {}", currentCount, |
| | | wordFile.getName(), pdfFile.getName()); |
| | | log.info("Start conversion [{}]: {} -> {}", currentCount, wordFile.getName(), pdfFile.getName()); |
| | | |
| | | // 执行转换 |
| | | LocalConverter.builder() |
| | | .officeManager(officeManager) |
| | | .build() |
| | | .convert(wordFile) |
| | | .convert(conversionSource) |
| | | .to(pdfFile) |
| | | .execute(); |
| | | |
| | | // 验证输出文件 |
| | | boolean success = validateOutputFile(pdfFile); |
| | | |
| | | long costTime = System.currentTimeMillis() - startTime; |
| | | if (success) { |
| | | log.info("转换成功 [{}]: {} -> {} (耗时: {}ms)", |
| | | log.info("Conversion succeeded [{}]: {} -> {} ({} ms)", |
| | | currentCount, wordFile.getName(), pdfFile.getName(), costTime); |
| | | } else { |
| | | log.warn("转换完成但文件可能异常 [{}]: {} (耗时: {}ms)", |
| | | log.warn("Conversion finished with invalid output [{}]: {} ({} ms)", |
| | | currentCount, wordFile.getName(), costTime); |
| | | } |
| | | |
| | | return success; |
| | | |
| | | } catch (Exception e) { |
| | | log.error("转换失败 [{}]: {}", currentCount, wordFile.getName(), e); |
| | | log.error("Conversion failed [{}]: {}", currentCount, wordFile.getName(), e.getMessage(), e); |
| | | return false; |
| | | } finally { |
| | | cleanupTemporarySource(wordFile, conversionSource); |
| | | } |
| | | } |
| | | |
| | | private File prepareConversionSource(File wordFile) { |
| | | if (!shouldNormalizeDocxFonts(wordFile)) { |
| | | return wordFile; |
| | | } |
| | | |
| | | try { |
| | | return normalizeDocxFonts(wordFile); |
| | | } catch (Exception e) { |
| | | log.warn("Docx font normalization failed, fallback to original file: {}", wordFile.getAbsolutePath(), e); |
| | | return wordFile; |
| | | } |
| | | } |
| | | |
| | | private boolean shouldNormalizeDocxFonts(File wordFile) { |
| | | return isLinux() && wordFile.getName().toLowerCase(Locale.ROOT).endsWith(".docx"); |
| | | } |
| | | |
| | | private boolean isLinux() { |
| | | return System.getProperty("os.name", "").toLowerCase(Locale.ROOT).contains("linux"); |
| | | } |
| | | |
| | | private File normalizeDocxFonts(File sourceFile) throws IOException { |
| | | Path tempFile = Files.createTempFile("estate-word-font-normalized-", ".docx"); |
| | | String fallbackFont = resolveFallbackFont(); |
| | | int replacedRuns = 0; |
| | | |
| | | try (InputStream inputStream = Files.newInputStream(sourceFile.toPath()); |
| | | XWPFDocument document = new XWPFDocument(inputStream); |
| | | OutputStream outputStream = Files.newOutputStream(tempFile)) { |
| | | replacedRuns += normalizeParagraphs(document.getParagraphs(), fallbackFont); |
| | | replacedRuns += normalizeTables(document.getTables(), fallbackFont); |
| | | |
| | | for (XWPFHeader header : document.getHeaderList()) { |
| | | replacedRuns += normalizeParagraphs(header.getParagraphs(), fallbackFont); |
| | | replacedRuns += normalizeTables(header.getTables(), fallbackFont); |
| | | } |
| | | |
| | | for (XWPFFooter footer : document.getFooterList()) { |
| | | replacedRuns += normalizeParagraphs(footer.getParagraphs(), fallbackFont); |
| | | replacedRuns += normalizeTables(footer.getTables(), fallbackFont); |
| | | } |
| | | |
| | | document.write(outputStream); |
| | | } |
| | | |
| | | log.info("Docx font normalization finished, fallbackFont={}, replacedRuns={}, source={}, temp={}", |
| | | fallbackFont, replacedRuns, sourceFile.getAbsolutePath(), tempFile); |
| | | return tempFile.toFile(); |
| | | } |
| | | |
| | | private int normalizeParagraphs(List<XWPFParagraph> paragraphs, String fallbackFont) { |
| | | int replacedRuns = 0; |
| | | for (XWPFParagraph paragraph : paragraphs) { |
| | | replacedRuns += normalizeParagraph(paragraph, fallbackFont); |
| | | } |
| | | return replacedRuns; |
| | | } |
| | | |
| | | private int normalizeParagraph(XWPFParagraph paragraph, String fallbackFont) { |
| | | int replacedRuns = 0; |
| | | for (XWPFRun run : paragraph.getRuns()) { |
| | | if (normalizeRunFont(run, fallbackFont)) { |
| | | replacedRuns++; |
| | | } |
| | | } |
| | | return replacedRuns; |
| | | } |
| | | |
| | | private int normalizeTables(List<XWPFTable> tables, String fallbackFont) { |
| | | int replacedRuns = 0; |
| | | for (XWPFTable table : tables) { |
| | | for (XWPFTableRow row : table.getRows()) { |
| | | for (XWPFTableCell cell : row.getTableCells()) { |
| | | replacedRuns += normalizeBodyElements(cell.getBodyElements(), fallbackFont); |
| | | } |
| | | } |
| | | } |
| | | return replacedRuns; |
| | | } |
| | | |
| | | private int normalizeBodyElements(List<IBodyElement> bodyElements, String fallbackFont) { |
| | | int replacedRuns = 0; |
| | | for (IBodyElement bodyElement : bodyElements) { |
| | | if (bodyElement instanceof XWPFParagraph paragraph) { |
| | | replacedRuns += normalizeParagraph(paragraph, fallbackFont); |
| | | } else if (bodyElement instanceof XWPFTable table) { |
| | | replacedRuns += normalizeTables(List.of(table), fallbackFont); |
| | | } |
| | | } |
| | | return replacedRuns; |
| | | } |
| | | |
| | | private boolean normalizeRunFont(XWPFRun run, String fallbackFont) { |
| | | boolean shouldReplace = shouldReplaceFont(run.getFontFamily()) |
| | | || shouldReplaceFont(run.getFontFamily(XWPFRun.FontCharRange.ascii)) |
| | | || shouldReplaceFont(run.getFontFamily(XWPFRun.FontCharRange.cs)) |
| | | || shouldReplaceFont(run.getFontFamily(XWPFRun.FontCharRange.eastAsia)) |
| | | || shouldReplaceFont(run.getFontFamily(XWPFRun.FontCharRange.hAnsi)); |
| | | |
| | | if (!shouldReplace) { |
| | | return false; |
| | | } |
| | | |
| | | run.setFontFamily(fallbackFont); |
| | | run.setFontFamily(fallbackFont, XWPFRun.FontCharRange.ascii); |
| | | run.setFontFamily(fallbackFont, XWPFRun.FontCharRange.cs); |
| | | run.setFontFamily(fallbackFont, XWPFRun.FontCharRange.eastAsia); |
| | | run.setFontFamily(fallbackFont, XWPFRun.FontCharRange.hAnsi); |
| | | return true; |
| | | } |
| | | |
| | | /** |
| | | * 验证输入文件 |
| | | */ |
| | | private boolean shouldReplaceFont(String fontName) { |
| | | return fontName != null && WINDOWS_CJK_FONTS.contains(fontName.trim()); |
| | | } |
| | | |
| | | private String resolveFallbackFont() { |
| | | String systemProperty = System.getProperty("estate.word.pdf.font"); |
| | | if (systemProperty != null && !systemProperty.isBlank()) { |
| | | return systemProperty.trim(); |
| | | } |
| | | |
| | | String environmentValue = System.getenv("ESTATE_WORD_PDF_FONT"); |
| | | if (environmentValue != null && !environmentValue.isBlank()) { |
| | | return environmentValue.trim(); |
| | | } |
| | | |
| | | return DEFAULT_FALLBACK_FONT; |
| | | } |
| | | |
| | | private void cleanupTemporarySource(File originalFile, File conversionSource) { |
| | | if (conversionSource == null || conversionSource.equals(originalFile)) { |
| | | return; |
| | | } |
| | | |
| | | try { |
| | | Files.deleteIfExists(conversionSource.toPath()); |
| | | } catch (IOException e) { |
| | | log.warn("Failed to delete temporary docx: {}", conversionSource.getAbsolutePath(), e); |
| | | } |
| | | } |
| | | |
| | | private void validateInputFile(File wordFile) { |
| | | if (wordFile == null) { |
| | | throw new IllegalArgumentException("输入文件不能为空"); |
| | | throw new IllegalArgumentException("Input file must not be null"); |
| | | } |
| | | |
| | | if (!wordFile.exists()) { |
| | | throw new IllegalArgumentException("输入文件不存在: " + wordFile.getAbsolutePath()); |
| | | throw new IllegalArgumentException("Input file does not exist: " + wordFile.getAbsolutePath()); |
| | | } |
| | | |
| | | if (!wordFile.isFile()) { |
| | | throw new IllegalArgumentException("输入路径不是文件: " + wordFile.getAbsolutePath()); |
| | | throw new IllegalArgumentException("Input path is not a file: " + wordFile.getAbsolutePath()); |
| | | } |
| | | |
| | | // 检查文件格式 |
| | | String fileName = wordFile.getName().toLowerCase(); |
| | | boolean supported = SUPPORTED_EXTENSIONS.stream() |
| | | .anyMatch(fileName::endsWith); |
| | | |
| | | String fileName = wordFile.getName().toLowerCase(Locale.ROOT); |
| | | boolean supported = SUPPORTED_EXTENSIONS.stream().anyMatch(fileName::endsWith); |
| | | if (!supported) { |
| | | throw new IllegalArgumentException("不支持的文件格式: " + fileName + |
| | | "\n支持的格式: " + SUPPORTED_EXTENSIONS); |
| | | throw new IllegalArgumentException("Unsupported file type: " + fileName + ", supported: " + SUPPORTED_EXTENSIONS); |
| | | } |
| | | |
| | | // 检查文件大小(限制 100MB) |
| | | long fileSize = wordFile.length(); |
| | | long maxSize = 100 * 1024 * 1024; // 100MB |
| | | long maxSize = 100L * 1024L * 1024L; |
| | | if (fileSize > maxSize) { |
| | | throw new IllegalArgumentException("文件过大: " + (fileSize / 1024 / 1024) + |
| | | "MB (最大支持 100MB)"); |
| | | throw new IllegalArgumentException("File is too large: " + (fileSize / 1024 / 1024) + "MB, max is 100MB"); |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * 准备输出目录 |
| | | */ |
| | | private void prepareOutputDirectory(File pdfFile) { |
| | | File parentDir = pdfFile.getParentFile(); |
| | | if (parentDir != null && !parentDir.exists()) { |
| | | boolean created = parentDir.mkdirs(); |
| | | if (!created) { |
| | | throw new RuntimeException("无法创建输出目录: " + parentDir.getAbsolutePath()); |
| | | } |
| | | if (parentDir != null && !parentDir.exists() && !parentDir.mkdirs()) { |
| | | throw new RuntimeException("Unable to create output directory: " + parentDir.getAbsolutePath()); |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * 验证输出文件 |
| | | */ |
| | | private boolean validateOutputFile(File pdfFile) { |
| | | if (!pdfFile.exists()) { |
| | | log.error("输出文件未创建: {}", pdfFile.getAbsolutePath()); |
| | | log.error("Output file was not created: {}", pdfFile.getAbsolutePath()); |
| | | return false; |
| | | } |
| | | |
| | | if (pdfFile.length() == 0) { |
| | | log.error("输出文件为空: {}", pdfFile.getAbsolutePath()); |
| | | log.error("Output file is empty: {}", pdfFile.getAbsolutePath()); |
| | | return false; |
| | | } |
| | | |
| | | // 检查文件头是否为 PDF |
| | | try (java.io.FileInputStream fis = new java.io.FileInputStream(pdfFile)) { |
| | | try (FileInputStream inputStream = new FileInputStream(pdfFile)) { |
| | | byte[] header = new byte[4]; |
| | | int read = fis.read(header); |
| | | int read = inputStream.read(header); |
| | | if (read >= 4) { |
| | | // PDF 文件头应该是 "%PDF" |
| | | boolean isPdf = header[0] == '%' && |
| | | header[1] == 'P' && |
| | | header[2] == 'D' && |
| | | header[3] == 'F'; |
| | | boolean isPdf = header[0] == '%' |
| | | && header[1] == 'P' |
| | | && header[2] == 'D' |
| | | && header[3] == 'F'; |
| | | if (!isPdf) { |
| | | log.warn("输出文件可能不是有效的 PDF 文件: {}", pdfFile.getAbsolutePath()); |
| | | log.warn("Output file is not a valid pdf: {}", pdfFile.getAbsolutePath()); |
| | | } |
| | | return isPdf; |
| | | } |
| | | } catch (Exception e) { |
| | | log.warn("验证 PDF 文件头失败", e); |
| | | log.warn("Failed to validate pdf header", e); |
| | | } |
| | | |
| | | return true; |
| | | } |
| | | |
| | | /** |
| | | * 批量转换 |
| | | * @param wordFiles Word 文件列表 |
| | | * @param outputDir 输出目录 |
| | | * @return 成功转换的文件数量 |
| | | */ |
| | | public int batchConvert(List<File> wordFiles, File outputDir) { |
| | | if (!outputDir.exists()) { |
| | | outputDir.mkdirs(); |
| | | if (!outputDir.exists() && !outputDir.mkdirs()) { |
| | | throw new RuntimeException("Unable to create batch output directory: " + outputDir.getAbsolutePath()); |
| | | } |
| | | |
| | | int successCount = 0; |
| | |
| | | try { |
| | | String baseName = getFileBaseName(wordFile); |
| | | File pdfFile = new File(outputDir, baseName + ".pdf"); |
| | | |
| | | log.info("批量转换进度: {}/{} - {}", i + 1, wordFiles.size(), wordFile.getName()); |
| | | |
| | | log.info("Batch conversion progress: {}/{} - {}", i + 1, wordFiles.size(), wordFile.getName()); |
| | | if (convert(wordFile, pdfFile)) { |
| | | successCount++; |
| | | } |
| | | |
| | | } catch (Exception e) { |
| | | log.error("批量转换失败: {}", wordFile.getName(), e); |
| | | log.error("Batch conversion failed: {}", wordFile.getName(), e); |
| | | } |
| | | } |
| | | |
| | | log.info("批量转换完成: 成功 {}/{}", successCount, wordFiles.size()); |
| | | log.info("Batch conversion finished: {}/{}", successCount, wordFiles.size()); |
| | | return successCount; |
| | | } |
| | | |
| | | /** |
| | | * 获取文件名(不含扩展名) |
| | | */ |
| | | private String getFileBaseName(File file) { |
| | | String name = file.getName(); |
| | | int dotIndex = name.lastIndexOf('.'); |
| | | return (dotIndex == -1) ? name : name.substring(0, dotIndex); |
| | | return dotIndex == -1 ? name : name.substring(0, dotIndex); |
| | | } |
| | | |
| | | /** |
| | | * 获取转换统计 |
| | | */ |
| | | public int getConversionCount() { |
| | | return conversionCount.get(); |
| | | } |
| | | |
| | | /** |
| | | * 重启 Office 服务 |
| | | */ |
| | | public void restart() { |
| | | log.info("正在重启 LibreOffice 服务..."); |
| | | log.info("Restarting LibreOffice service"); |
| | | shutdown(); |
| | | initializeOfficeManager(); |
| | | log.info("LibreOffice 服务重启完成"); |
| | | log.info("LibreOffice service restarted"); |
| | | } |
| | | |
| | | /** |
| | | * 关闭 Office 管理器 |
| | | */ |
| | | @PreDestroy |
| | | public void shutdown() { |
| | | if (officeManager != null && officeManager.isRunning()) { |
| | | try { |
| | | officeManager.stop(); |
| | | log.info("LibreOffice 服务已关闭"); |
| | | log.info("LibreOffice service stopped"); |
| | | } catch (OfficeException e) { |
| | | log.error("关闭 LibreOffice 服务时出错", e); |
| | | log.error("Failed to stop LibreOffice service", e); |
| | | } |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * 检查服务状态 |
| | | */ |
| | | public boolean isServiceRunning() { |
| | | return officeManager != null && officeManager.isRunning(); |
| | | } |