liujie
2026-04-09 3adfcc5a9829a0231bf95c1a7e3aaad1d28b05d5
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
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 = 100L * 1024L * 1024L;
        if (fileSize > maxSize) {
            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() && !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();
    }
}