Java 渲染 docx 文件,並生成 pdf 加水印

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

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

需求:

  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 包:java

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

第二種思路,使用 LibreOffice, LibreOffice 提供了一套 api 能夠提供給 java 程序調用。 因此使用 jodconverter 來調用 LibreOffice。以前網上搜到的教程早就已通過時。jodconverter 早就推出了 4.2 版本。最靠譜的文檔仍是直接看官方提供的wikigithub

2. 渲染模板

第一種思路,將 docx 裝換爲 html 的純文本格式,再使用 Java 現有的模板引擎(freemark,velocity)渲染內容。可是 docx 文件裝換爲 html 仍是會有極大的格式損失。 pass。spring

第二種思路。直接操做 docx 文檔在 docx 文檔中直接將佔位符替換爲內容。這樣保證了格式不會損失,可是沒有現成的模板引擎能夠支持 docx 的渲染。須要本身實現。apache

3. 水印

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

關鍵技術實現技術實現:

jodconverter + libreoffice 的使用

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

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

複製代碼

增長配置類:bash

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

歡迎關注個人微信公衆號

二維碼
相關文章
相關標籤/搜索