最近做了一個比較有意思的需求,實現的比較有意思。
需求:
- 用戶上傳一個 docx 文件,文檔中有佔位符若干,識別爲文檔模板。
- 用戶在前端可以將標籤拖拽到模板上,替代佔位符。
- 後端根據標籤,獲取標籤內容,生成 pdf 文檔並打上水印。
需求實現的難點:
- 模板文件來自業務方,財務,執行等角色,不可能使用類似 (freemark、velocity、Thymeleaf) 技術常用的模板標記語言。
- 文檔在上傳後需要解析,生成 html 供前端拖拽標籤,同時渲染的最終文檔是 pdf 。由於生成的 pdf 是正式文件,必須要求格式嚴格保證。
- 前端如果直接使用富文本編輯器,目前開源沒有比較滿意的實現,同時自主開發富文本需要極高技術含量。所以不考慮富文本編輯器的可能。
技術調研和技術選型(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); } } } } } }
這裏需要特別注意:
- 在解析的文檔中,
para.getParagraphText()
指的是獲取段落,para.getRuns()
應該指的是獲取詞。但是問題來了,獲取到的 runs 的劃分是一個謎。目前我也沒有找到規律,很有可能我們的佔位符被劃分到了多個run
中,如果我們簡單的針對run
做正則表達的替換,而要先把所有的runs
組合起來再進行正則替換。 - 在調用
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(); } }
字體:
- 使用文檔的時候,字體也同樣重要,如果你使用了 libreOffice 沒有的字體,比如宋體。需要把字體文件
xxx.ttf
cp xxx.ttc /usr/share/fonts fc-cache -fv
itextpdf
不支持漢字,需要提供額外的字體:
//字體路徑 String fontPath = "simsun.ttf" //設置字體 BaseFont baseFont = BaseFont.createFont(fontPath, BaseFont.IDENTITY_H, BaseFont.NOT_EMBEDDED);
後記
整個需求挺有意思,但是在查詢的時候發現中文文檔的質量實在堪憂,要麼極度過時,要麼就是大家互相抄襲。 查詢一個項目的技術文檔,最好的路徑應該如下:
項目官網 Getting Started == github demo > StackOverflow >>CSDN>>百度知道