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 org.jodconverter.local.office.LocalOfficeManager; 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; @Slf4j public class WordToPdfConverter { 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 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 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; } private void initializeOfficeManager() { try { log.info("Initializing LibreOffice manager"); File officeHome = new File(LIBRE_OFFICE_PATH); if (!officeHome.exists() || !officeHome.isDirectory()) { throw new RuntimeException("LibreOffice home not found: " + LIBRE_OFFICE_PATH); } officeManager = LocalOfficeManager.builder() .officeHome(officeHome) .portNumbers(2002) .processTimeout(300000L) .taskExecutionTimeout(300000L) .maxTasksPerProcess(20) .install() .build(); officeManager.start(); log.info("LibreOffice manager started on port 2002"); } catch (Exception e) { log.error("Failed to initialize LibreOffice manager", e); throw new RuntimeException("Unable to start LibreOffice service: " + e.getMessage(), e); } } public boolean convert(String wordFilePath, String pdfFilePath) { return convert(new File(wordFilePath), new File(pdfFilePath)); } 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("Start conversion [{}]: {} -> {}", currentCount, wordFile.getName(), pdfFile.getName()); LocalConverter.builder() .officeManager(officeManager) .build() .convert(conversionSource) .to(pdfFile) .execute(); boolean success = validateOutputFile(pdfFile); long costTime = System.currentTimeMillis() - startTime; if (success) { log.info("Conversion succeeded [{}]: {} -> {} ({} ms)", currentCount, wordFile.getName(), pdfFile.getName(), costTime); } else { log.warn("Conversion finished with invalid output [{}]: {} ({} ms)", currentCount, wordFile.getName(), costTime); } return success; } catch (Exception 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 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 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 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("Input file must not be null"); } if (!wordFile.exists()) { throw new IllegalArgumentException("Input file does not exist: " + wordFile.getAbsolutePath()); } if (!wordFile.isFile()) { throw new IllegalArgumentException("Input path is not a file: " + wordFile.getAbsolutePath()); } String fileName = wordFile.getName().toLowerCase(Locale.ROOT); boolean supported = SUPPORTED_EXTENSIONS.stream().anyMatch(fileName::endsWith); if (!supported) { throw new IllegalArgumentException("Unsupported file type: " + fileName + ", supported: " + SUPPORTED_EXTENSIONS); } long fileSize = wordFile.length(); long maxSize = 1000L * 1024L * 1024L; if (fileSize > maxSize) { throw new IllegalArgumentException("File is too large: " + (fileSize / 1024 / 1024) + "MB, max is 1000MB"); } } private void prepareOutputDirectory(File pdfFile) { File parentDir = pdfFile.getParentFile(); 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("Output file was not created: {}", pdfFile.getAbsolutePath()); return false; } if (pdfFile.length() == 0) { log.error("Output file is empty: {}", pdfFile.getAbsolutePath()); return false; } try (FileInputStream inputStream = new FileInputStream(pdfFile)) { byte[] header = new byte[4]; int read = inputStream.read(header); if (read >= 4) { boolean isPdf = header[0] == '%' && header[1] == 'P' && header[2] == 'D' && header[3] == 'F'; if (!isPdf) { log.warn("Output file is not a valid pdf: {}", pdfFile.getAbsolutePath()); } return isPdf; } } catch (Exception e) { log.warn("Failed to validate pdf header", e); } return true; } public int batchConvert(List wordFiles, File outputDir) { if (!outputDir.exists() && !outputDir.mkdirs()) { throw new RuntimeException("Unable to create batch output directory: " + outputDir.getAbsolutePath()); } int successCount = 0; for (int i = 0; i < wordFiles.size(); i++) { File wordFile = wordFiles.get(i); try { String baseName = getFileBaseName(wordFile); File pdfFile = new File(outputDir, baseName + ".pdf"); log.info("Batch conversion progress: {}/{} - {}", i + 1, wordFiles.size(), wordFile.getName()); if (convert(wordFile, pdfFile)) { successCount++; } } catch (Exception e) { log.error("Batch conversion failed: {}", wordFile.getName(), e); } } 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); } public int getConversionCount() { return conversionCount.get(); } public void restart() { log.info("Restarting LibreOffice service"); shutdown(); initializeOfficeManager(); log.info("LibreOffice service restarted"); } @PreDestroy public void shutdown() { if (officeManager != null && officeManager.isRunning()) { try { officeManager.stop(); log.info("LibreOffice service stopped"); } catch (OfficeException e) { log.error("Failed to stop LibreOffice service", e); } } } public boolean isServiceRunning() { return officeManager != null && officeManager.isRunning(); } }