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<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;
|
}
|
|
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<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("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<File> 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();
|
}
|
}
|