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>>百度知道

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章