首頁
編程語言
C語言|JAVA編程
Python編程
網頁編程
ASP編程|PHP編程
JSP編程
數據庫
MYSQL數據庫|SqlServer數據庫
Oracle數據庫|DB2數據庫
您现在的位置: 程式碼之家 >> 編程語言 >  >> Java 程式設計 >> Java 相關知識

Java 導出 Word 文檔:FreeMarker + XML 方案最佳實踐

     編輯:Java 相關知識

前言

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 結構、複雜邏輯受限企業級報表、合同、證書、通知單

二、核心原理:docx = ZIP + XML


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

關鍵點

  • document.xml 包含所有可見內容(段落、表格、圖片引用)
  • Word 保存為「Word XML 文檔 (.xml)」實際是展開的 Flat OPC 格式,非標準 docx
  • 標準做法:解壓 docx → 修改 word/document.xml → 重新打包為 docx

三、模板製作完整流程

步驟 1:Word 製作樣板

  1. 打開 Word,設計完整版面:頁邊距、頁眉頁腳、字體、段落間距、表格樣式、圖片佔位
  2. 使用「內容控制項」(開發工具 → 富文本內容控制項) 標記動態區域,設置標簽/標題為變量名
  3. 表格動態行:選中整行 → 右鍵「重複作為標題行」或使用內容控制項包裹整行

步驟 2:另存為 XML 並提取 document.xml


# 方法 A:另存為「Word XML 文檔 (*.xml)」
# 文件較大,包含所有部分,需手動提取  內容

# 方法 B(推薦):解壓 docx
unzip template.docx -d template_unzipped
# 核心模板:template_unzipped/word/document.xml

步驟 3:植入 FreeMarker 指令




  報表標題:
  【此處替換】




  報表標題:
  ${title}




  序號
      名稱
  <#list items as item>
  
    ${item_index + 1}
    ${item.name}
  
  



步驟 4:處理 XML 命名空間與特殊字符

  • docx 使用命名空間:`w:`, `wp:`, `a:`, `pic:`, `r:` 等
  • FreeMarker 輸出需保持 XML 合法:`<` → `<`,`>` → `>`,`&` → `&`
  • 使用 `?xml` 內建函數自動轉義:`${content?xml}`

四、Java 代碼實現

1. 依賴


<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>

2. 核心服務類


@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) {}
            });
    }
}

3. 圖片動態插入方案

docx 圖片通過 `` 引用 `word/media/imageN.png`,且 `document.xml.rels` 維護關係 ID。


// 方案:預置佔位圖片,運行時替換 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 個圖片關係,運行時替換文件即可
}

五、進階技巧與避坑

1. 表格跨頁斷行控制




  
      
  
  ...

2. 頁碼、目錄、引用字段更新

導出後需在 Word 中手動 `Ctrl+A` → `F9` 更新字段。程序化方案:

  • 使用 LibreOffice 無頭模式轉換:`soffice --headless --convert-to docx output.docx`
  • 或使用 Apache POI 打開後 `XWPFDocument.enforceUpdateFields()`

3. 複雜樣式保持

  • 模板中預定義所有樣式(標題 1-4、正文、表格樣式、列表樣式)
  • 動態內容僅套用樣式,不內聯格式
  • 使用 `w:pStyle`、`w:rStyle` 引用樣式 ID

4. 大數據量導出優化

  • 分批渲染:大表格分多次 `<#list>` 減少內存峰值
  • 流式打包:邊渲染邊寫入 ZipOutputStream,不全量載入內存
  • 異步導出:Controller 返回任務 ID,前端輪詢下載

5. 常見錯誤排查

現象原因解決
打開報錯「內容錯誤」XML 不合法(未閉合標籤、特殊字符未轉義)啟用 OutputFormat.XML、檢查模板語法
樣式丟失styles.xml 未打包、樣式 ID 不匹配完整解壓打包、保持 styles.xml 原樣
圖片不顯示rels 關係 ID 不對、媒體文件路徑錯誤預留關係、替換 media 文件
中文亂碼編碼不一致全鏈路 UTF-8、XML 聲明 encoding="UTF-8"
表格列寬變形動態內容撐開、未設置表格佈局設置 ``、``

六、Spring Boot Controller 示例


@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);
    }
}

七、替代方案:docx4j / POI-XWPF 模板

若不想手動處理 ZIP/XML,可考慮:

  • docx4j:功能最全,支持 OpenDoPE 規範,學習曲線陡峭
  • POI XWPF + 模板語法:`XWPFTemplate` (poi-tl)、`docx-stamper`
  • docx-stamper:基於 POI,語法類 Mustache,輕量級

總結

FreeMarker + docx XML 方案 將「樣式設計」交給 Word,將「數據邏輯」交給 FreeMarker,實現關注點分離。核心流程:Word 設計 → 解壓提取 document.xml → 植入 FTL 指令 → Java 渲染 → 重新打包。掌握命名空間處理、圖片關係管理、表格動態行、樣式複用、XML 合法性保證,即可構建穩健的企業級 Word 導出能力。

  1. 上一篇文章:
  2. 下一篇文章:
Copyright © 程式碼之家 All Rights Reserved