Java 渲染 docx 檔案,並生成 pdf 加水印

犀利豆發表於2018-08-15

原文地址:Java 渲染 docx 檔案,並生成 pdf 加水印

Java 渲染 docx 檔案,並生成 pdf 加水印

最近做了一個比較有意思的需求,實現的比較有意思。

需求:

  1. 使用者上傳一個 docx 檔案,文件中有佔位符若干,識別為文件模板。
  2. 使用者在前端可以將標籤拖拽到模板上,替代佔位符。
  3. 後端根據標籤,獲取標籤內容,生成 pdf 文件並打上水印。

需求實現的難點:

  1. 模板檔案來自業務方,財務,執行等角色,不可能使用類似 (freemark、velocity、Thymeleaf) 技術常用的模板標記語言。
  2. 文件在上傳後需要解析,生成 html 供前端拖拽標籤,同時渲染的最終文件是 pdf 。由於生成的 pdf 是正式檔案,必須要求格式嚴格保證。
  3. 前端如果直接使用富文字編輯器,目前開源沒有比較滿意的實現,同時自主開發富文字需要極高技術含量。所以不考慮富文字編輯器的可能。

技術調研和技術選型(Java 技術棧):

1. 對 docx 文件格式的轉換:

一頓google以後發現了 StackOverflow 上的這個回答:Converting docx into pdf in java 使用如下的 jar 包:

Apache POI 3.15
org.apache.poi.xwpf.converter.core-1.0.6.jar
org.apache.poi.xwpf.converter.pdf-1.0.6.jar
fr.opensagres.xdocreport.itext.extension-2.0.0.jar
itext-2.1.7.jar
ooxml-schemas-1.3.jar

複製程式碼

實際上寫了一個 Demo 測試以後發現,這套組合以及年久失修,對於複雜的 docx 文件都不能友好支援,程式碼不嚴謹,不時有 Nullpoint 的異常丟擲,還有莫名的jar包衝突的錯誤,最致命的一個問題是,不能嚴格保證格式。複雜的序號會出現各種問題。 pass。

第二種思路,使用 LibreOffice, LibreOffice 提供了一套 api 可以提供給 java 程式呼叫。 所以使用 jodconverter 來呼叫 LibreOffice。之前網上搜到的教程早就已經過時。jodconverter 早就推出了 4.2 版本。最靠譜的文件還是直接看官方提供的wiki

2. 渲染模板

第一種思路,將 docx 裝換為 html 的純文字格式,再使用 Java 現有的模板引擎(freemark,velocity)渲染內容。但是 docx 檔案裝換為 html 還是會有極大的格式損失。 pass。

第二種思路。直接操作 docx 文件在 docx 文件中直接將佔位符替換為內容。這樣保證了格式不會損失,但是沒有現成的模板引擎可以支援 docx 的渲染。需要自己實現。

3. 水印

這個相對比較簡單,直接使用 itextpdf 免費版就能解決問題。需要注意中文的問題字型,下文會逐步講解。

關鍵技術實現技術實現:

jodconverter + libreoffice 的使用

jodconverter 已經提供了一套完整的spring-boot解決方案,只需要在 pom.xml中增加如下配置:

<dependency>
    <groupId>org.jodconverter</groupId>
    <artifactId>jodconverter-local</artifactId>
    <version>4.2.0</version>
</dependenc>
<dependency>
    <groupId>org.jodconverter</groupId>
    <artifactId>jodconverter-spring-boot-starter</artifactId>
    <version>4.2.0</version>
</dependency>

複製程式碼

增加配置類:


@Configuration
public class ApplicationConfig {
    @Autowired
    private OfficeManager officeManager;
    @Bean
    public DocumentConverter documentConverter(){
        return LocalConverter.builder()
                .officeManager(officeManager)
                .build();
    }
}

複製程式碼

在配置檔案 application.properties 中新增:

# libreoffice 安裝目錄
jodconverter.local.office-home=/Applications/LibreOffice.app/Contents 
# 開啟jodconverter
jodconverter.local.enabled=true
複製程式碼

直接使用:

@Autowired
private DocumentConverter documentConverter;
private byte[] docxToPDF(InputStream inputStream) {
    try (ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream()) {
        documentConverter
                .convert(inputStream)
                .as(DefaultDocumentFormatRegistry.DOCX)
                .to(byteArrayOutputStream)
                .as(DefaultDocumentFormatRegistry.PDF)
                .execute();
        return byteArrayOutputStream.toByteArray();
    } catch (OfficeException | IOException e) {
        log.error("convert pdf error");
    }
    return null;
}    

複製程式碼

就將 docx 轉換為 pdf。注意流需要關閉,防止記憶體洩漏。

模板的渲染:

直接看程式碼:


@Service
public class OfficeService{

    //佔位符 {}
    private static final Pattern SymbolPattern = Pattern.compile("\\{(.+?)\\}", Pattern.CASE_INSENSITIVE);

    public byte[] replaceSymbol(InputStream inputStream,Map<String,String> symbolMap) throws IOException {
        XWPFDocument doc = new XWPFDocument(inputStream)        
        replaceSymbolInPara(doc,symbolMap);
        replaceInTable(doc,symbolMap)       
        try(ByteArrayOutputStream os = new ByteArrayOutputStream()) {
            doc.write(os);
            return os.toByteArray();
        }finally {
            inputStream.close();
        }
    }


    private int replaceSymbolInPara(XWPFDocument doc,Map<String,String> symbolMap){
        XWPFParagraph para;
        Iterator<XWPFParagraph> iterator = doc.getParagraphsIterator();
        while(iterator.hasNext()){
            para = iterator.next();
            replaceInPara(para,symbolMap);
        }
    }

    //替換正文
    private void replaceInPara(XWPFParagraph para,Map<String,String> symbolMap) {

        List<XWPFRun> runs;
        if (symbolMatcher(para.getParagraphText()).find()) {
            String text = para.getParagraphText();
            Matcher matcher3 = SymbolPattern.matcher(text);
            while (matcher3.find()) {
                String group = matcher3.group(1);
                String symbol = symbolMap.get(group);
                if (StringUtils.isBlank(symbol)) {
                    symbol = " ";
                }
                text = matcher3.replaceFirst(symbol);
                matcher3 = SymbolPattern.matcher(text);
            }
            runs = para.getRuns();
            String fontFamily = runs.get(0).getFontFamily();
            int fontSize = runs.get(0).getFontSize();
            XWPFRun xwpfRun = para.insertNewRun(0);
            xwpfRun.setFontFamily(fontFamily);
            xwpfRun.setText(text);
            if(fontSize > 0) {
                xwpfRun.setFontSize(fontSize);
            }
            int max = runs.size();
            for (int i = 1; i < max; i++) {
                para.removeRun(1);
            }

        }
    }

    //替換表格
    private void replaceInTable(XWPFDocument doc,Map<String,String> symbolMap) {
        Iterator<XWPFTable> iterator = doc.getTablesIterator();
        XWPFTable table;
        List<XWPFTableRow> rows;
        List<XWPFTableCell> cells;
        List<XWPFParagraph> paras;
        while (iterator.hasNext()) {
            table = iterator.next();
            rows = table.getRows();
            for (XWPFTableRow row : rows) {
                cells = row.getTableCells();
                for (XWPFTableCell cell : cells) {
                    paras = cell.getParagraphs();
                    for (XWPFParagraph para : paras) {
                        replaceInPara(para,symbolMap);
                    }
                }
            }
        }
    }
}
複製程式碼

這裡需要特別注意

  1. 在解析的文件中,para.getParagraphText()指的是獲取段落,para.getRuns()應該指的是獲取詞。但是問題來了,獲取到的 runs 的劃分是一個謎。目前我也沒有找到規律,很有可能我們的佔位符被劃分到了多個run中,如果我們簡單的針對 run 做正則表達的替換,而要先把所有的 runs 組合起來再進行正則替換。
  2. 在呼叫para.insertNewRun()的時候 run 並不會保持字型樣式和字型大小需要手動獲取並設定。 由於以上兩個蜜汁實現,所以就寫了一坨蜜汁程式碼才能保證正則替換和格式正確。

test 方法:

@Test
public void replaceSymbol() throws IOException {
    File file = new File("symbol.docx");
    InputStream inputStream = new FileInputStream(file);

    File outputFile = new File("out.docx");
    FileOutputStream outputStream = new FileOutputStream(outputFile);
    Map<String,String> map = new HashMap<>();
    map.put("tableName","水果價目表");
    map.put("name","蘋果");	
    map.put("price","1.5/斤");
    byte[] bytes = office.replaceSymbol(inputStream, map, );

    outputStream.write(bytes);
}

複製程式碼

replaceSymbol() 方法接受兩個引數,一個是輸入的docx檔案資料流,另一個是佔位符和內容的map。

這個方法使用前:

before

使用後:

after

增加水印:

pom.xml需要增加:

<!-- https://mvnrepository.com/artifact/com.itextpdf/itextpdf -->
<dependency>
    <groupId>com.itextpdf</groupId>
    <artifactId>itextpdf</artifactId>
    <version>5.5.13</version>
</dependency>
複製程式碼

增加水印的程式碼:

    public byte[] addWatermark(InputStream inputStream,String watermark) throws IOException, DocumentException {

        PdfReader reader = new PdfReader(inputStream);
        try(ByteArrayOutputStream os = new ByteArrayOutputStream()) {
            PdfStamper stamper = new PdfStamper(reader, os);
            int total = reader.getNumberOfPages() + 1;
            PdfContentByte content;
            // 設定字型
            BaseFont baseFont = BaseFont.createFont("simsun.ttf", BaseFont.IDENTITY_H, BaseFont.NOT_EMBEDDED);
            // 迴圈對每頁插入水印
            for (int i = 1; i < total; i++) {
                // 水印的起始
                content = stamper.getUnderContent(i);
                // 開始
                content.beginText();
                // 設定顏色
                content.setColorFill(new BaseColor(244, 244, 244));
                // 設定字型及字號
                content.setFontAndSize(baseFont, 50);
                // 設定起始位置
                content.setTextMatrix(400, 780);
                for (int x = 0; x < 5; x++) {
                    for (int y = 0; y < 5; y++) {
                        content.showTextAlignedKerned(Element.ALIGN_CENTER,
                                watermark,
                                (100f + x * 350),
                                (40.0f + y * 150),
                                30);
                    }
                }
                content.endText();
            }
            stamper.close();
            return os.toByteArray();
        }finally {
            reader.close();
        }

    }


複製程式碼

字型:

  1. 使用文件的時候,字型也同樣重要,如果你使用了 libreOffice 沒有的字型,比如宋體。需要把字型檔案 xxx.ttf
cp xxx.ttc /usr/share/fonts
fc-cache -fv
複製程式碼
  1. itextpdf 不支援漢字,需要提供額外的字型:
//字型路徑
String fontPath = "simsun.ttf"
//設定字型
BaseFont baseFont = BaseFont.createFont(fontPath, BaseFont.IDENTITY_H, BaseFont.NOT_EMBEDDED);

複製程式碼

後記

整個需求挺有意思,但是在查詢的時候發現中文文件的質量實在堪憂,要麼極度過時,要麼就是大家互相抄襲。 查詢一個專案的技術文件,最好的路徑應該如下:

專案官網 Getting Started == github demo > StackOverflow >> CSDN >> 百度知道

歡迎關注我的微信公眾號

二維碼

相關文章