Java 实现 Word 导出(个人记录,基于 Apache POI + 模板)
前言
在后台管理系统里,我们经常要把业务数据导出成 Word 文档,比如报价单、对账单,发给客户或留档。前段时间,我就接到了这样一个需求:把接受到的表格数据转化为对应的数据并插入到已有的 Word 模版中(我这里是报价单和对账单)。Word 版式复杂、中文字体要求高,如果全靠 Java 代码「画」表格,后期一改版式就要改代码,维护成本很高。
本项目(project-admin 模块;是我自己开发项目的模块名字)采用了一种另外的做法:在 Word 里做好 .docx 模板,后端用 Apache POI 读取模板、按锚点填充数据,最后在内存里生成文件并直接下载。本文把这套实现的来龙去脉讲清楚,大家照着做就能在本项目里跑通,也能迁移到自己的 Spring Boot 项目。
本方案的主要特点:
(1)版式与代码分离:表格边框、宋体、页眉页脚都在 Word 模板里维护,开发只负责填数据。
(2)全内存生成:不落临时文件,byte[] 直接写入 HttpServletResponse,适合容器部署。
(3)样式尽量保留:填充单元格时复制模板原有 Run 样式。中文默认宋体五号,避免 POI 改字后字体跑偏。
(4)业务逻辑内置:报价单合计自动累加并转中文大写;对账单自动写「本单合计」。
(5)表格能按照数据稍微进行调整:根据数据的行数进行调整
说明:
pom.xml里虽然还引入了poi-tl、freemarker、easyword,但当前 docx 导出链路并未使用,实际只用 Apache POI(poi-ooxml)。本文讲解的是仓库里真实在跑的代码。
前提内容
参考模板
因为需求方那边就描述了个大致内容(单纯本人自己做的)
完成之后
自己按照内容做了个小模板
下面就是我做出之后的结果
功能说明
导出报价单(基础)
报价单导出接口为 POST /office/docx/quote。调用方传入 JSON,服务端返回 .docx 附件。
请求体使用统一的 DocxGenerateParam:
@Data
public class DocxGenerateParam {
@ApiModelProperty(value = "模板类型,quote或statement")
private String templateType;
@ApiModelProperty(value = "固定信息")
private Map<String, Object> baseInfo;
@ApiModelProperty(value = "表格行")
private List<Map<String, Object>> tableRows;
}
报价单请求 JSON 示例:
{
"baseInfo": {
"customerName": "某某科技有限公司",
"quoteNo": "QT-20250626-001",
"date": "2025-06-26",
"paymentMethod": "月结30天",
"completionTime": "收到订单后7个工作日"
},
"tableRows": [
{
"productName": "工业传感器",
"model": "XS-100",
"unit": "台",
"quantity": "10",
"unitPrice": "1280.00",
"amount": "12800.00"
},
{
"productName": "安装配件包",
"model": "PK-02",
"unit": "套",
"quantity": "2",
"unitPrice": "350.00",
"amount": "700.00"
}
]
}
Controller 层代码很简洁,收到参数后交给 Service 生成字节数组,再写回响应:
@ApiOperation("下载报价单docx")
@PostMapping("/quote")
public void downloadQuote(@RequestBody DocxGenerateParam param, HttpServletResponse response) {
writeResponse(response, adminDocxService.generateQuoteDocx(param), buildQuoteFilename(param));
}
效果说明:打开下载的 docx,表头会显示 TO:客户名 NO:单号 DATE:日期;表格从第 2 行起填入品名、型号、数量、单价、金额;倒数第 2 行写合计(中文大写 + 数字);倒数第 1 行写付款方式和完成时间;页脚日期与 baseInfo.date 同步。
下载页面:这个是我网页点击导出后的结果
导出对账单
对账单接口为 POST /office/docx/statement,请求体结构相同,字段含义不同。
对账单请求 JSON 示例:
{
"baseInfo": {
"customerName": "某某贸易有限公司",
"statementNo": "ST-202506-008",
"date": "2025-06-26",
"statementMonth": "6"
},
"tableRows": [
{
"billDate": "2025-06-03",
"orderNo": "SO-240603-12",
"deliveryNo": "DN-88921",
"goodsName": "控制模块 A 型",
"amount": "5600.00",
"note": "已签收"
},
{
"billDate": "2025-06-18",
"orderNo": "SO-240618-03",
"deliveryNo": "DN-89102",
"goodsName": "线缆组件",
"amount": "1200.50",
"note": ""
}
]
}
@ApiOperation("下载对账单docx")
@PostMapping("/statement")
public void downloadStatement(@RequestBody DocxGenerateParam param, HttpServletResponse response) {
writeResponse(response, adminDocxService.generateStatementDocx(param), buildStatementFilename(param));
}
效果说明:表头同样写 TO / NO / DATE;标题段落变为 (06)月份对账单;表格填入账单日期、订单号、送货单号、货品、金额、备注;正文「本单合计」段落自动写入累加金额;页脚日期格式化为 2025年06月26日。
下载文件名规则
文件名由 buildFilenameByPrefix 自动生成,格式为:
报价单 - {客户名} - {yyyyMMdd}.docx
对账单 - {客户名} - {yyyyMMdd}.docx
客户名中的 \ / : * ? " < > | 会替换为 _,避免操作系统非法字符。日期取上海时区当天。
整体流程
我们来看一下,从前端发请求到拿到 Word 文件,中间经历了哪些步骤:
从这里可以看出,Controller 只负责「接参 + 下载」,真正的 Word 渲染逻辑都在 AdminDocxServiceImpl 里。
如何实现
下面按实际开发顺序,分步说明每一块是怎么做的。
第一步:引入 Maven 依赖
Word 导出依赖 Apache POI,版本在 project-common/pom.xml 中声明为 5.2.2:
<dependency>
<groupId>org.apache.poi</groupId>
<artifactId>poi</artifactId>
<version>5.2.2</version>
</dependency>
<dependency>
<groupId>org.apache.poi</groupId>
<artifactId>poi-ooxml</artifactId>
<version>5.2.2</version>
</dependency>
<dependency>
<groupId>org.apache.poi</groupId>
<artifactId>poi-scratchpad</artifactId>
<version>5.2.2</version>
</dependency>
<dependency>
<groupId>commons-io</groupId>
<artifactId>commons-io</artifactId>
<version>2.11.0</version>
</dependency>
生成 .docx 主要用到 poi-ooxml 里的 XWPFDocument。project-admin 通过依赖 project-common 间接引入上述包,无需在 admin 模块重复声明。
第二步:准备 Word 模板与锚点约定
模板放在 classpath 下:
project-admin/src/main/resources/templates/docx/quote-template.docx # 报价单
project-admin/src/main/resources/templates/docx/statement-template.docx # 对账单
模板在 Word 里排版好。代码不是用 {{变量}} 占位符,而是通过锚点文本定位要改的位置:
| 锚点 | 用途 |
|---|---|
段落含 TO |
改写表头:客户、单号、日期 |
段落含 月份对账单 |
对账单标题(仅对账单) |
段落含 本单合计 |
对账单合计金额(仅对账单) |
段落含 日期(且不含 DATE/TO) |
报价单页脚日期 |
段落含 日期+年+月+日 |
对账单页脚日期 |
| 第一张表格 | 填入明细行 |
大家改模板时,务必保留这些关键字,否则 Java 侧找不到对应段落,数据就填不进去。
第三步:Controller 接收参数并触发下载
AdminDocxController 映射路径 /office/docx,两个 POST 接口分别对应报价单和对账单。核心下载逻辑在 writeResponse:
private void writeResponse(HttpServletResponse response, byte[] bytes, String fileName) {
try {
String filename = URLEncoder.encode(fileName, StandardCharsets.UTF_8.toString());
response.setContentType("application/vnd.openxmlformats-officedocument.wordprocessingml.document");
response.setCharacterEncoding(StandardCharsets.UTF_8.toString());
response.setHeader("Content-Disposition", "attachment; filename=\"" + filename + "\"");
response.setContentLength(bytes.length);
OutputStream outputStream = response.getOutputStream();
outputStream.write(bytes);
outputStream.flush();
} catch (Exception e) {
throw new RuntimeException("下载文件失败", e);
}
}
从这里可以看出:
Content-Type必须是 docx 的 MIME,浏览器才会按 Word 文件处理。Content-Disposition: attachment触发下载而非在线打开。- 文件名做了
URLEncoder编码,避免中文乱码。
第四步:Service 加载模板并填充数据
AdminDocxServiceImpl 是核心。报价单渲染流程如下:
private byte[] renderQuoteTemplate(DocxGenerateParam param) {
try (InputStream inputStream = new ClassPathResource(QUOTE_TEMPLATE_PATH).getInputStream();
XWPFDocument document = new XWPFDocument(inputStream);
ByteArrayOutputStream outputStream = new ByteArrayOutputStream()) {
// 1. 解析 baseInfo
Map<String, Object> baseInfo = safeMap(param.getBaseInfo());
List<Map<String, Object>> rows = safeRows(param.getTableRows());
String customer = str(baseInfo, "customerName");
String quoteNo = str(baseInfo, "quoteNo");
String date = str(baseInfo, "date");
// ...
// 2. 找含 "TO" 的段落,写表头
for (XWPFParagraph paragraph : document.getParagraphs()) {
String txt = paragraph.getText();
if (txt != null && txt.contains("TO")) {
String line = "TO:" + customer + " NO:" + quoteNo + " DATE:" + date;
setParagraphText(paragraph, line);
break;
}
}
// 3. 填第一张表:明细 + 合计 + 备注
if (!document.getTables().isEmpty()) {
XWPFTable table = document.getTables().get(0);
int startRow = 1;
int maxFillRows = Math.max(0, table.getRows().size() - 3);
BigDecimal total = BigDecimal.ZERO;
for (int i = 0; i < rows.size() && i < maxFillRows; i++) {
// setCellText 填序号、品名、型号、单位、数量、单价、金额
total = total.add(parseDecimal(amount));
}
// 倒数第 2 行:中文大写合计
// 倒数第 1 行:付款方式、完成时间
}
// 4. 同步页脚日期
syncQuoteDateFooter(document, date);
document.write(outputStream);
return outputStream.toByteArray();
} catch (Exception e) {
throw new RuntimeException("生成报价单失败", e);
}
}
对账单的 renderStatementTemplate 逻辑类似,额外多了「月份对账单」标题和「本单合计」段落。
报价单表格行数上限:maxFillRows = table.getRows().size() - 3,即模板里要预留「表头行 + 若干数据行 + 合计行 + 备注行」。数据行超过预留行数会被截断,不会动态插行——这是固定版式模板的折中做法。
第五步:填充时保留样式(宋体兜底)
POI 直接 setText 容易丢字体。本项目在 setCellText / setParagraphText 里采用「先复制模板 Run 样式,再写新文字」:
private void setCellText(XWPFTableRow row, int index, String text) {
// ...
if (!paragraph.getRuns().isEmpty()) {
templateStyle = extractRunStyle(paragraph.getRuns().get(0));
}
// 清空旧 runs
for (int i = paragraph.getRuns().size() - 1; i >= 0; i--) {
paragraph.removeRun(i);
}
XWPFRun run = paragraph.createRun();
applyRunStyleOrDefault(run, templateStyle);
run.setText(text == null ? "" : text);
}
若模板单元格没有可复制的样式,则走 applyDefaultFont:宋体、五号(21 half-points),并设置 eastAsia 字体通道:
private void applyDefaultFont(XWPFRun run) {
run.setFontFamily(STANDARD_FONT);
CTRPr rPr = run.getCTR().addNewRPr();
CTFonts fonts = rPr.addNewRFonts();
fonts.setAscii(STANDARD_FONT);
fonts.setHAnsi(STANDARD_FONT);
fonts.setEastAsia(STANDARD_FONT);
fonts.setHint(STHint.EAST_ASIA);
// Sz / SzCs = 21
}
大家如果导出后发现中文变成英文字体,多半是没设 eastAsia 和 hint,可以对照这段代码检查。
第六步:金额累加与中文大写
报价单合计行由服务端计算,不依赖前端传总额:
setCellText(totalRow, 1, toChineseCurrency(total) + " ¥" + total.toPlainString());
toChineseCurrency 把 13500.00 转成类似 壹万叁仟伍佰元整 的格式,符合国内报价单习惯。对账单则在含「本单合计」的段落写入 本单合计;¥{总额}。
注意
使用过程中有几个常见坑,提前说明:
1. 报错 Zip bomb detected
现象:加载模板时抛出 IOException: Zip bomb detected!
原因:POI 5.x 默认 ZipSecureFile.minInflateRatio = 0.01,Office 模板内嵌的高压缩字体资源可能低于该阈值。
处理:本项目在 AdminDocxServiceImpl 静态块中已设置:
ZipSecureFile.setMinInflateRatio(0.005d);
仅适用于内部可信模板,不要随意对不可信上传文件放宽。
2. 中文显示为英文字体
现象:填充后中文变成 Calibri 等英文字体。
原因:Word 中英文字体分 ascii / eastAsia 等通道,只调 setFontFamily 不够。
处理:使用 applyDefaultFont,同时设置 fonts.setEastAsia 和 fonts.setHint(STHint.EAST_ASIA);或优先复制模板原有 CTRPr 样式。
3. 改了模板后数据填不进去
现象:下载的 docx 里表头、合计仍是模板空白或旧占位文字。
原因:代码通过 contains("TO")、contains("本单合计") 等锚点定位段落,模板里删改这些关键字后匹配失败。
处理:改模板时保留锚点,或与开发约定后同步改 Java 匹配逻辑。
4. 报价单明细行超出模板预留行数
现象:后面几行明细没有出现在 Word 里。
原因:maxFillRows = table.getRows().size() - 3,超出部分被丢弃。
处理:在 Word 模板里增加数据行,或改为 POI 动态 insertNewTableRow / 迁移 poi-tl 循环表格。
核心代码
下面贴上本项目完整的导出相关源码,方便大家直接对照。有些内容我自己没处理,做得很粗糙。
DocxGenerateParam.java
package com.uptotrap.group.dto;
import io.swagger.annotations.ApiModelProperty;
import lombok.Data;
import java.util.List;
import java.util.Map;
@Data
public class DocxGenerateParam {
@ApiModelProperty(value = "模板类型,quote或statement")
private String templateType;
@ApiModelProperty(value = "固定信息")
private Map<String, Object> baseInfo;
@ApiModelProperty(value = "表格行")
private List<Map<String, Object>> tableRows;
}
AdminDocxController.java
package com.uptotrap.group.controller;
import com.uptotrap.group.dto.DocxGenerateParam;
import com.uptotrap.group.service.AdminDocxService;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import javax.annotation.Resource;
import javax.servlet.http.HttpServletResponse;
import java.io.OutputStream;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.time.LocalDate;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
import java.util.Map;
@Api(tags = "admin-docx文件生成")
@RestController
@RequestMapping("/office/docx")
public class AdminDocxController {
private static final ZoneId SHANGHAI_ZONE = ZoneId.of("Asia/Shanghai");
@Resource
private AdminDocxService adminDocxService;
@ApiOperation("下载报价单docx")
@PostMapping("/quote")
public void downloadQuote(@RequestBody DocxGenerateParam param, HttpServletResponse response) {
writeResponse(response, adminDocxService.generateQuoteDocx(param), buildQuoteFilename(param));
}
@ApiOperation("下载对账单docx")
@PostMapping("/statement")
public void downloadStatement(@RequestBody DocxGenerateParam param, HttpServletResponse response) {
writeResponse(response, adminDocxService.generateStatementDocx(param), buildStatementFilename(param));
}
private void writeResponse(HttpServletResponse response, byte[] bytes, String fileName) {
try {
String filename = URLEncoder.encode(fileName,
StandardCharsets.UTF_8.toString());
response.setContentType("application/vnd.openxmlformats-officedocument.wordprocessingml.document");
response.setCharacterEncoding(StandardCharsets.UTF_8.toString());
response.setHeader("Content-Disposition", "attachment; filename=\"" + filename + "\"");
response.setContentLength(bytes.length);
OutputStream outputStream = response.getOutputStream();
outputStream.write(bytes);
outputStream.flush();
} catch (Exception e) {
throw new RuntimeException("下载文件失败", e);
}
}
private String buildQuoteFilename(DocxGenerateParam param) {
return buildFilenameByPrefix("报价单", param);
}
private String buildStatementFilename(DocxGenerateParam param) {
return buildFilenameByPrefix("对账单", param);
}
private String buildFilenameByPrefix(String prefix, DocxGenerateParam param) {
Map<String, Object> baseInfo = param == null ? null : param.getBaseInfo();
String customerName = "未命名客户";
String date = LocalDate.now(SHANGHAI_ZONE).format(DateTimeFormatter.BASIC_ISO_DATE);
if (baseInfo != null) {
Object customerObj = baseInfo.get("customerName");
if (customerObj != null && !String.valueOf(customerObj).trim().isEmpty()) {
customerName = String.valueOf(customerObj).trim();
}
}
customerName = customerName.replaceAll("[\\\\/:*?\"<>|]", "_");
return prefix + " - " + customerName + " - " + date + ".docx";
}
}
AdminDocxServiceImpl.java
package com.uptotrap.group.service.impl;
import com.uptotrap.group.dto.DocxGenerateParam;
import com.uptotrap.group.service.AdminDocxService;
import org.apache.poi.openxml4j.util.ZipSecureFile;
import org.apache.poi.xwpf.usermodel.XWPFDocument;
import org.apache.poi.xwpf.usermodel.XWPFParagraph;
import org.apache.poi.xwpf.usermodel.XWPFRun;
import org.apache.poi.xwpf.usermodel.XWPFTable;
import org.apache.poi.xwpf.usermodel.XWPFTableCell;
import org.apache.poi.xwpf.usermodel.XWPFTableRow;
import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTFonts;
import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTHpsMeasure;
import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTRPr;
import org.openxmlformats.schemas.wordprocessingml.x2006.main.STHint;
import org.springframework.core.io.ClassPathResource;
import org.springframework.stereotype.Service;
import java.io.ByteArrayOutputStream;
import java.io.InputStream;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.math.BigInteger;
import java.util.Collections;
import java.util.List;
import java.util.Map;
@Service
public class AdminDocxServiceImpl implements AdminDocxService {
private static final String QUOTE_TEMPLATE_PATH = "templates/docx/quote-template.docx";
private static final String STATEMENT_TEMPLATE_PATH = "templates/docx/statement-template.docx";
private static final double DOCX_MIN_INFLATE_RATIO = 0.005d;
private static final String STANDARD_FONT = "宋体";
private static final int WUHAO_HALF_POINTS = 21; // 五号 = 10.5pt = 21 half-points
static {
// Some office templates embed compressed font resources that are below POI default ratio (0.01).
// Relax to 0.005 for trusted internal templates to avoid false-positive zip bomb detection.
ZipSecureFile.setMinInflateRatio(DOCX_MIN_INFLATE_RATIO);
}
@Override
public byte[] generateQuoteDocx(DocxGenerateParam param) {
return renderQuoteTemplate(param);
}
@Override
public byte[] generateStatementDocx(DocxGenerateParam param) {
return renderStatementTemplate(param);
}
private byte[] renderQuoteTemplate(DocxGenerateParam param) {
try (InputStream inputStream = new ClassPathResource(QUOTE_TEMPLATE_PATH).getInputStream();
XWPFDocument document = new XWPFDocument(inputStream);
ByteArrayOutputStream outputStream = new ByteArrayOutputStream()) {
Map<String, Object> baseInfo = safeMap(param.getBaseInfo());
List<Map<String, Object>> rows = safeRows(param.getTableRows());
String customer = str(baseInfo, "customerName");
String quoteNo = str(baseInfo, "quoteNo");
String date = str(baseInfo, "date");
String paymentMethod = str(baseInfo, "paymentMethod");
String completionTime = str(baseInfo, "completionTime");
for (XWPFParagraph paragraph : document.getParagraphs()) {
String txt = paragraph.getText();
if (txt != null && txt.contains("TO")) {
String line = "TO:" + customer + " NO:" + quoteNo + " DATE:" + date;
setParagraphText(paragraph, line);
break;
}
}
if (!document.getTables().isEmpty()) {
XWPFTable table = document.getTables().get(0);
int startRow = 1;
int maxFillRows = Math.max(0, table.getRows().size() - 3);
BigDecimal total = BigDecimal.ZERO;
for (int i = 0; i < rows.size() && i < maxFillRows; i++) {
Map<String, Object> row = rows.get(i);
XWPFTableRow tableRow = table.getRow(startRow + i);
setCellText(tableRow, 0, String.valueOf(i + 1));
setCellText(tableRow, 1, str(row, "productName"));
setCellText(tableRow, 2, str(row, "model"));
setCellText(tableRow, 3, str(row, "unit"));
setCellText(tableRow, 4, str(row, "quantity"));
setCellText(tableRow, 5, str(row, "unitPrice"));
String amount = str(row, "amount");
setCellText(tableRow, 6, amount);
total = total.add(parseDecimal(amount));
}
XWPFTableRow totalRow = table.getRow(table.getRows().size() - 2);
if (totalRow != null) {
setCellText(totalRow, 1, toChineseCurrency(total) + " ¥" + total.toPlainString());
}
XWPFTableRow remarkRow = table.getRow(table.getRows().size() - 1);
if (remarkRow != null && !remarkRow.getTableCells().isEmpty()) {
XWPFTableCell remarkCell = remarkRow.getCell(0);
if (remarkCell != null && remarkCell.getParagraphs().size() >= 3) {
setParagraphText(remarkCell.getParagraphs().get(1),
"1.付款方式 " + paymentMethod + " 2.完成时间 " + completionTime);
}
}
}
syncQuoteDateFooter(document, date);
document.write(outputStream);
return outputStream.toByteArray();
} catch (Exception e) {
throw new RuntimeException("生成报价单失败", e);
}
}
private byte[] renderStatementTemplate(DocxGenerateParam param) {
try (InputStream inputStream = new ClassPathResource(STATEMENT_TEMPLATE_PATH).getInputStream();
XWPFDocument document = new XWPFDocument(inputStream);
ByteArrayOutputStream outputStream = new ByteArrayOutputStream()) {
Map<String, Object> baseInfo = safeMap(param.getBaseInfo());
List<Map<String, Object>> rows = safeRows(param.getTableRows());
String customer = str(baseInfo, "customerName");
String statementNo = str(baseInfo, "statementNo");
String date = str(baseInfo, "date");
String statementMonth = resolveStatementMonth(str(baseInfo, "statementMonth"), date);
for (XWPFParagraph paragraph : document.getParagraphs()) {
String txt = paragraph.getText();
if (txt != null && txt.contains("TO")) {
String line = "TO:" + customer + " NO:" + statementNo + " DATE:" + date;
setParagraphText(paragraph, line);
break;
}
}
for (XWPFParagraph paragraph : document.getParagraphs()) {
String txt = paragraph.getText();
if (txt != null && normalizeText(txt).contains("月份对账单")) {
setParagraphText(paragraph, "(" + statementMonth + ")月份对账单");
break;
}
}
BigDecimal total = BigDecimal.ZERO;
if (!document.getTables().isEmpty()) {
XWPFTable table = document.getTables().get(0);
int startRow = 1;
for (int i = 0; i < rows.size(); i++) {
int rowIndex = startRow + i;
if (rowIndex >= table.getRows().size()) {
break;
}
Map<String, Object> row = rows.get(i);
XWPFTableRow tableRow = table.getRow(rowIndex);
setCellText(tableRow, 0, str(row, "billDate"));
setCellText(tableRow, 1, str(row, "orderNo"));
setCellText(tableRow, 2, str(row, "deliveryNo"));
setCellText(tableRow, 3, str(row, "goodsName"));
String amount = str(row, "amount");
setCellText(tableRow, 4, amount);
setCellText(tableRow, 5, str(row, "note"));
total = total.add(parseDecimal(amount));
}
}
for (XWPFParagraph paragraph : document.getParagraphs()) {
String txt = paragraph.getText();
if (txt != null && txt.contains("本单合计")) {
setParagraphText(paragraph, "本单合计;¥" + total.toPlainString());
break;
}
}
syncStatementFooterDate(document, date);
document.write(outputStream);
return outputStream.toByteArray();
} catch (Exception e) {
throw new RuntimeException("生成对账单失败", e);
}
}
private void setCellText(XWPFTableRow row, int index, String text) {
if (row == null || index >= row.getTableCells().size()) {
return;
}
XWPFTableCell cell = row.getCell(index);
if (cell == null) {
return;
}
CTRPr templateStyle = null;
XWPFParagraph paragraph;
if (cell.getParagraphs().isEmpty()) {
paragraph = cell.addParagraph();
} else {
paragraph = cell.getParagraphs().get(0);
if (!paragraph.getRuns().isEmpty()) {
templateStyle = extractRunStyle(paragraph.getRuns().get(0));
}
for (int i = paragraph.getRuns().size() - 1; i >= 0; i--) {
paragraph.removeRun(i);
}
}
XWPFRun run = paragraph.createRun();
applyRunStyleOrDefault(run, templateStyle);
run.setText(text == null ? "" : text);
}
private void setParagraphText(XWPFParagraph paragraph, String text) {
CTRPr templateStyle = null;
if (!paragraph.getRuns().isEmpty()) {
templateStyle = extractRunStyle(paragraph.getRuns().get(0));
}
int runSize = paragraph.getRuns().size();
for (int i = runSize - 1; i >= 0; i--) {
paragraph.removeRun(i);
}
XWPFRun run = paragraph.createRun();
applyRunStyleOrDefault(run, templateStyle);
run.setText(text == null ? "" : text);
}
private String str(Map<String, Object> map, String key) {
Object value = map.get(key);
return value == null ? "" : String.valueOf(value).trim();
}
private Map<String, Object> safeMap(Map<String, Object> map) {
return map == null ? Collections.emptyMap() : map;
}
private List<Map<String, Object>> safeRows(List<Map<String, Object>> rows) {
return rows == null ? Collections.emptyList() : rows;
}
private BigDecimal parseDecimal(String value) {
try {
if (value == null || value.trim().isEmpty()) {
return BigDecimal.ZERO;
}
return new BigDecimal(value.trim());
} catch (Exception e) {
return BigDecimal.ZERO;
}
}
private void syncQuoteDateFooter(XWPFDocument document, String date) {
boolean replaced = false;
for (XWPFParagraph paragraph : document.getParagraphs()) {
String txt = paragraph.getText();
if (txt != null
&& txt.contains("日期")
&& !txt.contains("DATE")
&& !txt.contains("TO")) {
setParagraphText(paragraph, "日期 日期 " + date);
replaced = true;
}
}
if (!replaced) {
// Fallback: ensure at least one footer-like date line is present
for (int i = document.getParagraphs().size() - 1; i >= 0; i--) {
XWPFParagraph paragraph = document.getParagraphs().get(i);
String txt = paragraph.getText();
if (txt != null && !txt.trim().isEmpty()) {
setParagraphText(paragraph, "日期 日期 " + date);
break;
}
}
}
}
private String toChineseCurrency(BigDecimal amount) {
final String[] digit = {"零", "壹", "贰", "叁", "肆", "伍", "陆", "柒", "捌", "玖"};
final String[] unit = {"", "拾", "佰", "仟", "万", "拾", "佰", "仟", "亿"};
BigDecimal normalized = amount == null ? BigDecimal.ZERO : amount.setScale(2, RoundingMode.HALF_UP);
long integerPart = normalized.longValue();
int jiao = normalized.movePointRight(1).abs().intValue() % 10;
int fen = normalized.movePointRight(2).abs().intValue() % 10;
if (integerPart == 0) {
return "零元" + (jiao == 0 && fen == 0 ? "整" : "");
}
String intStr = String.valueOf(integerPart);
StringBuilder intBuilder = new StringBuilder();
boolean lastZero = false;
for (int i = 0; i < intStr.length(); i++) {
int num = intStr.charAt(i) - '0';
int pos = intStr.length() - 1 - i;
if (num == 0) {
if (!lastZero && pos != 4 && pos != 8) {
intBuilder.append(digit[0]);
}
if (pos == 4 || pos == 8) {
intBuilder.append(unit[pos]);
}
lastZero = true;
} else {
intBuilder.append(digit[num]).append(unit[pos]);
lastZero = false;
}
}
String result = intBuilder.toString().replaceAll("零+", "零")
.replaceAll("零万", "万")
.replaceAll("零亿", "亿")
.replaceAll("亿万", "亿")
.replaceAll("零$", "") + "元";
if (jiao == 0 && fen == 0) {
return result + "整";
}
if (jiao > 0) {
result += digit[jiao] + "角";
}
if (fen > 0) {
result += digit[fen] + "分";
}
return result;
}
private String resolveStatementMonth(String statementMonth, String date) {
if (statementMonth != null) {
String month = statementMonth.replaceAll("\\D", "");
if (!month.isEmpty()) {
return month.length() == 1 ? "0" + month : month.substring(0, 2);
}
}
if (date != null && date.matches("\\d{4}-\\d{2}-\\d{2}")) {
return date.substring(5, 7);
}
return "";
}
private void syncStatementFooterDate(XWPFDocument document, String date) {
String dateCn = formatDateCn(date);
if (dateCn.isEmpty()) {
return;
}
for (XWPFParagraph paragraph : document.getParagraphs()) {
String txt = paragraph.getText();
if (txt != null && txt.contains("日期") && txt.contains("年") && txt.contains("月") && txt.contains("日")) {
setParagraphText(paragraph, "日期:" + dateCn);
break;
}
}
}
private String formatDateCn(String date) {
if (date == null || !date.matches("\\d{4}-\\d{2}-\\d{2}")) {
return "";
}
return date.substring(0, 4) + "年" + date.substring(5, 7) + "月" + date.substring(8, 10) + "日";
}
private String normalizeText(String text) {
if (text == null) {
return "";
}
return text.replaceAll("\\s+", "");
}
private void applyRunStyleOrDefault(XWPFRun targetRun, CTRPr templateStyle) {
if (templateStyle != null) {
targetRun.getCTR().setRPr((CTRPr) templateStyle.copy());
return;
}
applyDefaultFont(targetRun);
}
private CTRPr extractRunStyle(XWPFRun run) {
try {
if (run != null && run.getCTR() != null && run.getCTR().getRPr() != null) {
return (CTRPr) run.getCTR().getRPr().copy();
}
} catch (Exception ignored) {
// Fall back to default font when source style is unavailable.
}
return null;
}
private void applyDefaultFont(XWPFRun run) {
run.setFontFamily(STANDARD_FONT);
CTRPr rPr = run.getCTR().addNewRPr();
CTFonts fonts = rPr.addNewRFonts();
fonts.setAscii(STANDARD_FONT);
fonts.setHAnsi(STANDARD_FONT);
fonts.setEastAsia(STANDARD_FONT);
fonts.setHint(STHint.EAST_ASIA);
CTHpsMeasure size = rPr.addNewSz();
size.setVal(BigInteger.valueOf(WUHAO_HALF_POINTS));
CTHpsMeasure sizeCs = rPr.addNewSzCs();
sizeCs.setVal(BigInteger.valueOf(WUHAO_HALF_POINTS));
}
}
写在最后
第一次写博客望大家友好交流,纯新手写手,多多关照
更多推荐
所有评论(0)