Java 導出 Word 方案眾多(POI、Jacob、iText、Jacob),但 FreeMarker + Word XML (docx) 方案 以樣式保真度高、模板可視化編輯、無依賴 Office 環境、跨平台穩定等優勢,成為企業級報表導出首選。本文詳解完整實施流程。
| 方案 | 原理 | 優點 | 缺點 | 推薦場景 |
|---|---|---|---|---|
| Apache POI | 直接操作 OOXML 結構 | 功能全、官方維護 | API 繁瑣、樣式代碼量大、性能一般 | 簡單表格、動態結構生成 |
| Jacob/COM | 調用 Word COM 組件 | 完美還原、支持宏/打印 | 僅 Windows、需安裝 Office、併發差、穩定性差 | 服務端自動化打印、宏執行 |
| iText/rtf | 生成 RTF/PDF | 跨平台 | Word 兼容性差、樣式受限 | PDF 導出 |
| FreeMarker + docx XML | 模板引擎渲染 XML | 所見即所得、樣式完全由 Word 控制、無 Office 依賴、高性能 | 模板製作需懂 docx 結構、複雜邏輯受限 | 企業級報表、合同、證書、通知單 |
document.docx (ZIP 包)
├── [Content_Types].xml
├── _rels/
│ └── .rels
├── word/
│ ├── document.xml <-- 核心內容(FreeMarker 模板)
│ ├── styles.xml <-- 樣式定義
│ ├── numbering.xml <-- 編號/列表
│ ├── header1.xml
│ ├── footer1.xml
│ ├── media/
│ │ └── image1.png
│ └── _rels/
│ └── document.xml.rels
└── docProps/
├── core.xml
└── app.xml
# 方法 A:另存為「Word XML 文檔 (*.xml)」
# 文件較大,包含所有部分,需手動提取 內容
# 方法 B(推薦):解壓 docx
unzip template.docx -d template_unzipped
# 核心模板:template_unzipped/word/document.xml
報表標題:
【此處替換】
報表標題:
${title}
序號
名稱
<#list items as item>
${item_index + 1}
${item.name}
#list>
<dependency>
<groupId>org.freemarker</groupId>
<artifactId>freemarker</artifactId>
<version>2.3.32</version>
</dependency>
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-compress</artifactId>
<version>1.26.0</version>
</dependency>
@Service
public class DocxExportService {
private final Configuration freemarkerCfg;
private final Path templateDir;
public DocxExportService(@Value("${docx.template.dir}") String templateDir) throws IOException {
this.templateDir = Paths.get(templateDir);
this.freemarkerCfg = new Configuration(Configuration.VERSION_2_3_32);
freemarkerCfg.setDirectoryForTemplateLoading(this.templateDir.toFile());
freemarkerCfg.setDefaultEncoding("UTF-8");
freemarkerCfg.setTemplateExceptionHandler(TemplateExceptionHandler.RETHROW_HANDLER);
// 關鍵:XML 輸出模式,自動轉義特殊字符
freemarkerCfg.setOutputFormat(OutputFormat.XML);
}
public byte[] generateDocx(String templateName, Map<String, Object> dataModel)
throws IOException, TemplateException {
Path templatePath = templateDir.resolve(templateName);
if (!Files.exists(templatePath)) {
throw new IllegalArgumentException("模板不存在: " + templateName);
}
// 1. 解壓模板到臨時目錄
Path tempDir = Files.createTempDirectory("docx_" + System.currentTimeMillis());
try (ZipFile zipFile = new ZipFile(templatePath.toFile())) {
zipFile.stream().forEach(entry -> {
try {
Path target = tempDir.resolve(entry.getName());
Files.createDirectories(target.getParent());
if (!entry.isDirectory()) {
Files.copy(zipFile.getInputStream(entry), target);
}
} catch (IOException e) {
throw new UncheckedIOException(e);
}
});
}
// 2. 渲染 document.xml
Path documentXml = tempDir.resolve("word/document.xml");
String renderedXml;
try (StringWriter writer = new StringWriter()) {
Template template = freemarkerCfg.getTemplate(documentXml.toFile().getName());
// 需要將臨時目錄加入模板加載路徑
Configuration tempCfg = new Configuration(Configuration.VERSION_2_3_32);
tempCfg.setDirectoryForTemplateLoading(tempDir.resolve("word").toFile());
tempCfg.setDefaultEncoding("UTF-8");
tempCfg.setOutputFormat(OutputFormat.XML);
Template t = tempCfg.getTemplate("document.xml");
t.process(dataModel, writer);
renderedXml = writer.toString();
}
// 3. 寫回渲染後的 XML
Files.writeString(documentXml, renderedXml, StandardCharsets.UTF_8);
// 4. 重新打包為 docx
ByteArrayOutputStream baos = new ByteArrayOutputStream();
try (ZipOutputStream zos = new ZipOutputStream(baos)) {
Files.walk(tempDir)
.filter(Files::isRegularFile)
.forEach(file -> {
try {
String entryName = tempDir.relativize(file).toString().replace("\\", "/");
zos.putNextEntry(new ZipEntry(entryName));
Files.copy(file, zos);
zos.closeEntry();
} catch (IOException e) {
throw new UncheckedIOException(e);
}
});
}
// 5. 清理臨時目錄
deleteRecursively(tempDir);
return baos.toByteArray();
}
private void deleteRecursively(Path path) throws IOException {
Files.walk(path)
.sorted(Comparator.reverseOrder())
.forEach(p -> {
try { Files.delete(p); } catch (IOException ignored) {}
});
}
}
docx 圖片通過 `
// 方案:預置佔位圖片,運行時替換 media 目錄下文件
public void addImagesToDocx(Path tempDir, Map<String, byte[]> images) throws IOException {
Path mediaDir = tempDir.resolve("word/media");
Files.createDirectories(mediaDir);
// 1. 保存圖片文件
int index = 1;
for (Map.Entry<String, byte[]> entry : images.entrySet()) {
String ext = guessExtension(entry.getValue()); // 根據魔數判斷
Path imgFile = mediaDir.resolve("image" + index + "." + ext);
Files.write(imgFile, entry.getValue());
index++;
}
// 2. 更新 document.xml.rels (可選,若模板已預留足夠關係)
// 通常模板預留 10 個圖片關係,運行時替換文件即可
}
...
導出後需在 Word 中手動 `Ctrl+A` → `F9` 更新字段。程序化方案:
| 現象 | 原因 | 解決 |
|---|---|---|
| 打開報錯「內容錯誤」 | XML 不合法(未閉合標籤、特殊字符未轉義) | 啟用 OutputFormat.XML、檢查模板語法 |
| 樣式丟失 | styles.xml 未打包、樣式 ID 不匹配 | 完整解壓打包、保持 styles.xml 原樣 |
| 圖片不顯示 | rels 關係 ID 不對、媒體文件路徑錯誤 | 預留關係、替換 media 文件 |
| 中文亂碼 | 編碼不一致 | 全鏈路 UTF-8、XML 聲明 encoding="UTF-8" |
| 表格列寬變形 | 動態內容撐開、未設置表格佈局 | 設置 ` |
@RestController
@RequestMapping("/export")
public class ExportController {
@Autowired
private DocxExportService exportService;
@Autowired
private ReportDataService dataService;
@GetMapping("/monthly-report/{year}/{month}")
public ResponseEntity monthlyReport(@PathVariable int year,
@PathVariable int month) {
// 1. 獲取數據
Map<String, Object> model = dataService.buildMonthlyReportModel(year, month);
// 2. 生成 docx
byte[] docxBytes;
try {
docxBytes = exportService.generateDocx("monthly-report.ftl", model);
} catch (Exception e) {
return ResponseEntity.internalServerError().build();
}
// 3. 返回下載
String filename = String.format("月報_%d_%02d.docx", year, month);
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.parseMediaType(
"application/vnd.openxmlformats-officedocument.wordprocessingml.document"));
headers.setContentDispositionFormData("attachment",
new String(filename.getBytes(StandardCharsets.UTF_8), StandardCharsets.ISO_8859_1));
headers.setContentLength(docxBytes.length);
return ResponseEntity.ok().headers(headers).body(docxBytes);
}
}
若不想手動處理 ZIP/XML,可考慮:
FreeMarker + docx XML 方案 將「樣式設計」交給 Word,將「數據邏輯」交給 FreeMarker,實現關注點分離。核心流程:Word 設計 → 解壓提取 document.xml → 植入 FTL 指令 → Java 渲染 → 重新打包。掌握命名空間處理、圖片關係管理、表格動態行、樣式複用、XML 合法性保證,即可構建穩健的企業級 Word 導出能力。