摘要
在比较老的生产环境中,实现基于文档模板的生成 docx 类型文档的功能。
前言
开发工具:idea
jdk版本:1.7
poi版本:3.17
poi-tl版本:1.5.1
研发背景:用户提出要求,在老项目的导出doc文档功能基础上,进行优化,实现能够导出 docx 文档的功能。在尝试了很多种方案后,个人觉得 poi-tl 加 poi 混合方案,更加符合需求,且工作量相对较少些。
注:ai 工具提供了很大帮助
正文
一、方案比较和研究
(一)docx文档类型研究
docx 文档是一个压缩包,里面的document.xml是文档内容,其他文件是关联关系,样式等文件。
(二)先列出同事和我想到的所有方案,包括以下几种:
1、用freemarker生成xml文件,再压缩成docx(压缩这一步很麻烦,需要自己生成相关xml文件)
2、仍然使用系统生成doc,再通过poi等其他第三方库把doc转成docx文件中(用wps效果更好,但是需要联网,且有费用)
3、本地下载LibreOffice,java通过命令行调用LibreOffice进行文件转换(已实践,转出来的文件打开是xml代码,不能正常显示)
4、poi-tl 加 poi 混合方式(基本能够满足用户需求,可以通过配合公式转换ooxml功能,实现插入公式的功能)
二、poi-tl 加 poi 混合方式实现
(一)poi-tl 渲染docx类型文档模板
参考poi-tl的官方文档 deepoove.com/poi-tl/1.5.…
这个版本是java7的最高版本,可以满足基本的模板功能,但是不支持公式。
(二)LaTeX公式转换成ooxml
借助第三方工具实现,需要自己编写一部分代码.
(三)poi 插入公式到指定位置
可以先用poi-tl在渲染模板时,插入特定字符串到指定位置,再用poi操作docx文件在指定位置替换为公式ooxml代码。
后记
过程比较费时,尝试了各种方案后,发现均不理想。在ai帮助下,才找到一个比较符合需求的方案。成果比较简单,过程比较折磨。
最后,同事尝试使用了一个付费工具aspose,可以直接实现doc转docx,如果想要省事且能够承担费用的话,可以直接使用 aspose。
aspose官网:用于处理 Word 文档格式的专业本地和基于云的解决方案
附部分代码
/**
* 合同文档生成示例
*/
public static void generateContractDocument() {
try {
// 准备合同数据
ContractData contractData = new ContractData();
// 合同基本信息
contractData.setContractNo("HT20240120001");
contractData.setContractName("软件开发服务合同");
contractData.setPartyA("某某科技有限公司");
contractData.setPartyB("某某客户公司");
contractData.setSignDate( dateFormat.format(new Date()));
contractData.setEffectiveDate("2024年01月25日");
contractData.setExpireDate("2025年01月24日");
// 项目信息
contractData.setProjectName("OA办公系统开发项目");
contractData.setProjectDuration("12个月");
contractData.setTotalAmount(150000.00);
contractData.setAmountInWords("人民币壹拾伍万元整");
contractData.setDeposit(30000.00);
// 条件数据
contractData.setIncludeWarranty(true);
contractData.setWarrantyPeriod("12个月");
contractData.setIncludeTraining(true);
contractData.setTrainingHours(16);
// 循环数据 - 付款计划
// List<PaymentItem> paymentItems = new ArrayList<PaymentItem>();
// paymentItems.add(new PaymentItem("合同签订后3个工作日内", 30000.00, 20, "首付款"));
// paymentItems.add(new PaymentItem("项目中期验收后", 75000.00, 50, "中期款"));
// paymentItems.add(new PaymentItem("项目最终验收后", 45000.00, 30, "尾款"));
// contractData.put("paymentItems", paymentItems);
RowRenderData header = RowRenderData.build(new TextRenderData("FFFFFF", "姓名"), new TextRenderData("FFFFFF", "学历"));
RowRenderData row0 = RowRenderData.build("张三", "研究生");
RowRenderData row1 = RowRenderData.build("李四", "博士");
RowRenderData row2 = RowRenderData.build("王五", "博士后");
RowRenderData row3 = RowRenderData.build(new TextRenderData("000000", "Sayi卅一"), new TextRenderData("000000", "第一行内容\n第二行内容"));
contractData.setPaymentItems(new MiniTableRenderData(header, Arrays.asList(row0, row1, row2, row3)));
// 循环数据 - 服务内容
contractData.setServices(new NumbericRenderData(new ArrayList<TextRenderData>() {
{
add(new TextRenderData("系统需求分析与设计"));
add(new TextRenderData("核心功能模块开发"));
add(new TextRenderData("系统测试与调试"));
add(new TextRenderData("部署与实施"));
add(new TextRenderData("用户培训与技术支持"));
}
}));
// 加载合同模板
String templatePath = "/contract_template.docx";
Configure.ConfigureBuilder builder = Configure.newBuilder();
builder.setElMode( ELMode.SPEL_MODE);
XWPFTemplate template = XWPFTemplate.compile(templatePath, builder.build()).render(contractData);
// 输出合同
String outputPath = "output/软件开发合同_" + contractData.getContractNo() + ".docx";
FileOutputStream out = null;
try {
out = new FileOutputStream(outputPath);
template.write(out);
logger.info("合同生成成功: {}", outputPath);
} finally {
if (out != null) out.close();
if (template != null) template.close();
}
} catch (Exception e) {
logger.error("生成合同失败", e);
}
}
/**
* 插入公式示例
*/
public static void main( String[] args ) {
try {
String filePath = inputDocx;
String bookmarkName = "aaaaa"; // 修改为你的书签名
// 1. 查找书签 - 表格
BookmarkLocation location = findBookmarkInTables( filePath, bookmarkName );
if ( location != null ) {
System.out.println( "找到书签: " + location );
// 2. 在书签位置插入
// String ooxml = "<w:r><w:t>插入到表格书签位置</w:t></w:r>";
String ooxml = "<?xml version="1.0" encoding="UTF-8"?>\n" +
"<w:p xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" " +
"xmlns:m="http://schemas.openxmlformats.org/officeDocument/2006/math">" +
"<m:oMathPara><m:oMathParaPr><m:jc m:val="left"/></m:oMathParaPr><m:oMath xmlns:m="http://schemas.openxmlformats.org/officeDocument/2006/math" xmlns:mml="http://www.w3.org/1998/Math/MathML"><m:rad><m:radPr><m:degHide m:val="on"/></m:radPr><m:deg/><m:e><m:r><m:t>2</m:t></m:r></m:e></m:rad></m:oMath></m:oMathPara>" +
"</w:p>";
insertAtBookmarkLocation( filePath, output, ooxml, bookmarkName );
System.out.println( "插入完成" );
} else {
System.out.println( "未找到书签: " + bookmarkName );
}
bookmarkName = "bbbbb"; // 修改为你的书签名
// 1. 查找书签 - 段落
location = findBookmarkInTables( filePath, bookmarkName );
if ( location != null ) {
System.out.println( "找到书签: " + location );
// 2. 在书签位置插入
// String ooxml = "<w:r><w:t>插入到表格书签位置</w:t></w:r>";
String ooxml = "<?xml version="1.0" encoding="UTF-8"?>\n" +
"<w:p xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" " +
"xmlns:m="http://schemas.openxmlformats.org/officeDocument/2006/math">" +
"<m:oMathPara><m:oMathParaPr><m:jc m:val="left"/></m:oMathParaPr><m:oMath xmlns:m="http://schemas.openxmlformats.org/officeDocument/2006/math" xmlns:mml="http://www.w3.org/1998/Math/MathML"><m:rad><m:radPr><m:degHide m:val="on"/></m:radPr><m:deg/><m:e><m:r><m:t>2</m:t></m:r></m:e></m:rad></m:oMath></m:oMathPara>" +
"</w:p>";
insertAtBookmarkLocation( output, output, ooxml, bookmarkName );
System.out.println( "插入完成" );
} else {
System.out.println( "未找到书签: " + bookmarkName );
}
} catch ( Exception e ) {
e.printStackTrace();
}
}
/**
* 在书签位置插入(支持表格)
*/
public static void insertAtBookmarkLocation( String inputPath, String outputPath,
String ooxmlContent, String bookmarkName ) throws Exception {
BookmarkLocation location = findBookmarkInTables( inputPath, bookmarkName );
if ( location == null ) {
throw new RuntimeException( "未找到书签: " + bookmarkName );
}
try ( FileInputStream fis = new FileInputStream( inputPath );
XWPFDocument document = new XWPFDocument( fis ) ) {
if ( location.type == BookmarkType.PARAGRAPH ) {
// 在段落中书签后插入
insertAfterParagraph( document, location.paragraphIndex, ooxmlContent );
} else {
// 在表格单元格中书签后插入
insertInTableCell( document, location, ooxmlContent );
}
// 保存
try ( FileOutputStream fos = new FileOutputStream( outputPath ) ) {
document.write( fos );
}
}
}
/**
* 在段落后插入
*/
private static void insertAfterParagraph( XWPFDocument document, int paragraphIndex, String ooxmlContent ) throws IOException, ParserConfigurationException, SAXException {
// 3. 修复OOXML
String fixedOoxml = fixOoxml( ooxmlContent );
// 4. 解析OOXML
DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
factory.setNamespaceAware( true );
DocumentBuilder builder = factory.newDocumentBuilder();
org.w3c.dom.Document xmlDoc = builder.parse(
new ByteArrayInputStream( fixedOoxml.getBytes( "UTF-8" ) ) );
CTBody body = document.getDocument().getBody();
Node bodyNode = body.getDomNode();
org.w3c.dom.Element ooxmlElement = xmlDoc.getDocumentElement();
Node importedNode = bodyNode.getOwnerDocument().importNode( ooxmlElement, true );
// 5. 找到书签所在的段落节点
List<XWPFParagraph> paragraphs = document.getParagraphs();
if ( paragraphIndex >= 0 && paragraphIndex < paragraphs.size() ) {
CTP bookmarkCTP = paragraphs.get( paragraphIndex ).getCTP();
Node bookmarkNode = bookmarkCTP.getDomNode();
// 6. 在书签段落后插入
if ( bookmarkNode.getNextSibling() != null ) {
bodyNode.insertBefore( importedNode, bookmarkNode.getNextSibling() );
} else {
// 插入到末尾
bodyNode.appendChild( importedNode );
}
} else {
// 插入到文档开头
if ( bodyNode.getFirstChild() != null ) {
bodyNode.insertBefore( importedNode, bodyNode.getFirstChild() );
} else {
bodyNode.appendChild( importedNode );
}
}
}
/**
* 在表格单元格中插入
*/
private static void insertInTableCell( XWPFDocument document, BookmarkLocation location, String ooxmlContent ) throws ParserConfigurationException, IOException, SAXException {
// 获取表格
XWPFTable table = document.getTables().get( location.tableIndex );
XWPFTableRow row = table.getRows().get( location.rowIndex );
XWPFTableCell cell = row.getTableCells().get( location.cellIndex );
// 获取单元格中的段落列表
List<XWPFParagraph> cellParagraphs = cell.getParagraphs();
// 3. 修复OOXML
String fixedOoxml = fixOoxml( ooxmlContent );
// 4. 解析OOXML
DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
factory.setNamespaceAware( true );
DocumentBuilder builder = factory.newDocumentBuilder();
org.w3c.dom.Document xmlDoc = builder.parse(
new ByteArrayInputStream( fixedOoxml.getBytes( "UTF-8" ) ) );
CTBody body = document.getDocument().getBody();
Node bodyNode = body.getDomNode();
org.w3c.dom.Element ooxmlElement = xmlDoc.getDocumentElement();
Node importedNode = bodyNode.getOwnerDocument().importNode( ooxmlElement, true );
if ( location.cellParagraphIndex >= 0 && location.cellParagraphIndex < cellParagraphs.size() ) {
CTP bookmarkCTP = cellParagraphs.get( location.cellParagraphIndex ).getCTP();
Node bookmarkNode = bookmarkCTP.getDomNode();
// 6. 在书签段落后插入
if ( bookmarkNode.getNextSibling() != null ) {
bodyNode.insertBefore( importedNode, bookmarkNode.getNextSibling() );
} else {
// 插入到末尾
// bodyNode.appendChild( importedNode );
bookmarkNode.getParentNode().appendChild( importedNode );
}
} else {
// 插入到文档开头
if ( bodyNode.getFirstChild() != null ) {
bodyNode.insertBefore( importedNode, bodyNode.getFirstChild() );
} else {
bodyNode.appendChild( importedNode );
}
}
}
private static String fixOoxml( String ooxml ) {
if ( !ooxml.contains( "xmlns:w=" ) ) {
ooxml = ooxml.replaceFirst( "<([^>]+)>",
"<$1 xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">" );
}
return ooxml;
}
/**
* 简单查找表格中的书签
*/
public static BookmarkLocation findBookmarkInTables( String filePath, String bookmarkName ) throws Exception {
try ( FileInputStream fis = new FileInputStream( filePath );
XWPFDocument document = new XWPFDocument( fis ) ) {
// 1. 先检查普通段落
List<XWPFParagraph> paragraphs = document.getParagraphs();
for ( int i = 0; i < paragraphs.size(); i++ ) {
if ( hasBookmark( paragraphs.get( i ), bookmarkName ) ) {
return new BookmarkLocation( BookmarkType.PARAGRAPH, i, -1, -1, -1, -1 );
}
}
// 2. 检查表格
List<XWPFTable> tables = document.getTables();
for ( int tableIndex = 0; tableIndex < tables.size(); tableIndex++ ) {
XWPFTable table = tables.get( tableIndex );
List<XWPFTableRow> rows = table.getRows();
for ( int rowIndex = 0; rowIndex < rows.size(); rowIndex++ ) {
XWPFTableRow row = rows.get( rowIndex );
List<XWPFTableCell> cells = row.getTableCells();
for ( int cellIndex = 0; cellIndex < cells.size(); cellIndex++ ) {
XWPFTableCell cell = cells.get( cellIndex );
List<XWPFParagraph> cellParagraphs = cell.getParagraphs();
for ( int paraIndex = 0; paraIndex < cellParagraphs.size(); paraIndex++ ) {
if ( hasBookmark( cellParagraphs.get( paraIndex ), bookmarkName ) ) {
return new BookmarkLocation( BookmarkType.TABLE, -1,
tableIndex, rowIndex, cellIndex, paraIndex );
}
}
}
}
}
}
return null;
}
private static boolean hasBookmark( XWPFParagraph paragraph, String bookmarkName ) {
for ( CTBookmark bookmark : paragraph.getCTP().getBookmarkStartList() ) {
if ( bookmarkName.equals( bookmark.getName() ) ) {
return true;
}
}
return false;
}
/**
* 书签位置信息
*/
public static class BookmarkLocation {
public BookmarkType type;
public int paragraphIndex; // 用于段落
public int tableIndex; // 用于表格
public int rowIndex;
public int cellIndex;
public int cellParagraphIndex;
public BookmarkLocation( BookmarkType type, int paragraphIndex,
int tableIndex, int rowIndex, int cellIndex, int cellParagraphIndex ) {
this.type = type;
this.paragraphIndex = paragraphIndex;
this.tableIndex = tableIndex;
this.rowIndex = rowIndex;
this.cellIndex = cellIndex;
this.cellParagraphIndex = cellParagraphIndex;
}
@Override
public String toString() {
if ( type == BookmarkType.PARAGRAPH ) {
return String.format( "书签在段落[%d]", paragraphIndex );
} else {
return String.format( "书签在表格[%d] 行[%d] 列[%d] 段落[%d]",
tableIndex, rowIndex, cellIndex, cellParagraphIndex );
}
}
}
public enum BookmarkType {
PARAGRAPH, TABLE
}
/**
* 转换LaTeX为MathML - 简化版本(改为静态方法)
*/
private static String convertLatexToMathML(String latex, boolean inline) throws Exception {
String cacheKey = latex + "_" + inline;
if (latexCache.containsKey(cacheKey)) {
return latexCache.get(cacheKey);
}
// 处理度数、分、秒符号
latex = latex
.replace("°", "^{\circ}");
for (Map.Entry<String, String> entry : specialCharsMap.entrySet()) {
latex = latex.replace(entry.getKey(), entry.getValue());
}
SnuggleSession session = engine.createSession();
// 直接使用前端格式,不进行任何修复
String wrappedLatex = inline ? "$" + latex + "$" : "\[" + latex + "\]";
SnuggleInput input = new SnuggleInput(wrappedLatex);
session.parseInput(input);
if (!session.getErrors().isEmpty()) {
throw new RuntimeException("LaTeX解析错误:" + session.getErrors());
}
String mathML = session.buildXMLString();
if (ValidateUtil.isNotEmpty(mathML)) {
for (Map.Entry<String, String> entry : specialCharsMap.entrySet()) {
mathML = mathML.replace(entry.getValue(), entry.getKey());
}
}
latexCache.put(cacheKey, mathML);
return mathML;
}
docx模板
{{contractName}}
合同编号:{{contractNo}}
甲方(委托方):{{partyA}}
乙方(受托方):{{partyB}}
一、合同基本信息
-
项目名称:{{projectName}}
-
合同期限:{{projectDuration}}
-
签约日期:{{signDate}}
-
生效日期:{{effectiveDate}}
-
终止日期:{{expireDate}}
二、合同金额
-
合同总金额:{{totalAmount}}元(大写:{{amountInWords}})
-
订金:{{deposit}}元
三、服务内容
{{*services}}
四、付款计划
{{#paymentItems}}
五、其他条款
{{includeWarranty? ' 1. 质保期:'+ warrantyPeriod: ''}}
{{ includeTraining? '2. 培训服务:'+ trainingHours + ' 小时': ''}}
甲方(盖章):____________________
乙方(盖章):____________________
签约日期:{{signDate}}