前端报表导出成word文档(含echarts图表)
一、问题背景:
前端vue做的各种维度的报表,原来是通过前端整体截屏导出成PDF,但部分报表在遇到跨页时会被截断,客户体验极差。然后又考虑客户可能需要修改报表中的一些内容,因此需要导出成word文档解决跨页截断和满足修改报表内容的问题。前期解决方案预研时试过jacob、poi方案,但jacob只能用于windows平台(要引用一个dll文件),并且jacob和poi都存在样式方面的难题。后来通过其他渠道了解了freemarker,于是通过freemarker的把前端请求的报表数据填充到模板文件,生成word文档(导出功能由后端java实现)
二、效果图
首先上一张效果图,由于数据保密性,故前端页面的报表原样就不展示,导出的word文档的效果图和页面报表几乎一样
#三、功能点
- 文档标题
- 文档标题下方生成日期
- 文档总体情况概述
- 每个echarts图表的标题、图片、图注
- 水印
#四、解决方案
利用freemarker将前端传入的json格式数据填充入事先设计好的模板文件并生成word文档
#五、实现流程
Created with Raphaël 2.2.0 开始 新建word文档 按照页面报表布局与样式设计文档模板 替换内容为占位符 另存为xml文件 xml模板占位符是否分离 占位符完整 将图表生成的base64编码手动替换成占位符 将文档保存到工程resource/freemarker/template目录 编写代码调用freemarker api生成word文档 访问swagger接口页面测试 下载,打开查看效果 yes no
#六、实现步骤
##1. 设计模板
按照前端报表展示样式,设计模板,并将模板中需要动态被参数填充的部分使用占位符代替,如标题使用${title},图表标题使用${title_1}、${title_2}、${title_3},图表总结词用${summary_1},${summary_2},${summary_3},以此类推.下图为使用占位符替换之后的word模板
##2. 另存模板为xml
上一步设计好模板并替换关键内容为占位符后,需要保存成xml模板文件,然后将xml模板文件中的图片base64编码替换成占位符,例如下面模板片段
<pkg:part pkg:name="/word/media/image16.png" pkg:contentType="image/png" pkg:compression="store">
<pkg:binaryData>${base64_11}</pkg:binaryData>
</pkg:part>
<pkg:part pkg:name="/word/media/image11.png" pkg:contentType="image/png" pkg:compression="store">
<pkg:binaryData>${base64_9_1}</pkg:binaryData>
</pkg:part>
<pkg:part pkg:name="/word/media/image9.png" pkg:contentType="image/png" pkg:compression="store">
<pkg:binaryData>${base64_8_2}</pkg:binaryData>
</pkg:part>
<pkg:part pkg:name="/word/media/image10.png" pkg:contentType="image/png" pkg:compression="store">
<pkg:binaryData>${base64_8_3}</pkg:binaryData>
</pkg:part>
<pkg:part pkg:name="/word/media/image8.png" pkg:contentType="image/png" pkg:compression="store">
<pkg:binaryData>${base64_8_1}</pkg:binaryData>
</pkg:part>
##3. 新建maven工程
本人使用的开发工具是Idea 2018.1版本,创建maven项目并创建包名,结构如下:
export-doc
└─src
│ ├─main
│ │ ├─java
│ │ │ └─com
│ │ │ └─zhuxl
│ │ │ └─exportdoc
│ │ │ │
│ │ │ ├─component
│ │ │ │ └─handler
│ │ │ │
│ │ │ ├─configuration
│ │ │ │
│ │ │ ├─controller
│ │ │ │
│ │ │ ├─entity
│ │ │ │
│ │ │ ├─service
│ │ │ │ │
│ │ │ │ └─impl
│ │ │ │
│ │ │ └─util
│ │ │
│ │ └─resources
│ │
│ └─test
│ └─java
└─pom.xml
##4. 添加相关依赖
- 添加spring boot依赖
本demo项目基于spring boot框架,因此需要添加spring-boot-starter-web依赖,并且创建启动类Application.java
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
<version>1.5.13.RELEASE</version>
<optional>true</optional>
</dependency>
@SpringBootApplication(exclude = {DataSourceAutoConfiguration.class})
public class Application {
public static void main(String[] args) {
SpringApplication.run(Application.class, args);
}
}
(exclude = {DataSourceAutoConfiguration.class})
参数表示不自动加载参数连接数据库,因为本demo无数据库连接,仅演示service里调用工具类方法导出word,不需要操作数据库,因此需要添加这个参数,否则启动会报连接数据库异常。
- 添加swagger依赖
本demo导出word报表请求参数为json格式,数据量非常大(因为有echarts报表base64编码),请求方式为POST,为了便于测试,因此集成swagger
<dependency>
<groupId>com.didispace</groupId>
<artifactId>spring-boot-starter-swagger</artifactId>
<version>1.4.1.RELEASE</version>
</dependency>
- 添加lombok依赖
demo中请求参数使用lombok注解@Data或@Getter,@Setter,可以不用写请求对象的getter和setter方法,在项目编译阶段会自动生成getter和setter方法。
<!-- LOMBOK begin -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.16.20</version>
</dependency>
<!-- LOMBOK end -->
- 添加fastjson依赖
demo可能会使用到JSONObject类来设置异常时接口返回的数据
<!-- FASTJSON begin -->
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>fastjson</artifactId>
<version>1.2.31</version>
</dependency>
<!-- FASTJSON end -->
- 添加freemarker依赖
该依赖为本次功能实现的核心,主要利用freemarker的api将请求数据构造的map和模板文件作为参数生成word文件,并返回File文件对象,最后使用response的输出流将文件返回
<!-- FREEMARKER begin -->
<dependency>
<groupId>org.freemarker</groupId>
<artifactId>freemarker</artifactId>
<version>2.3.28</version>
</dependency>
<!-- FREEMARKER end -->
##5. 创建接口请求参数对象类
使用java类来接收请求的json数据
@Data
@ApiModel(value = "贫困人群报表导出请求对象")
public class ReportExportWordRequest {
@ApiModelProperty(value = "区域级别", name = "unitLevel")
private Integer unitLevel;
@ApiModelProperty(value = "区域编码", name = "unitCode")
private String unitCode;
@ApiModelProperty(value = "报表类型", name = "type", notes = "poverty:贫困人群报告;disable:残疾人群报告;poverty_disable:贫困且残疾人群报告")
private String type;
@ApiModelProperty(value = "报表标题", name = "title")
private String title;
@ApiModelProperty(value = "报告水印", name = "watermark")
private String watermark;
@ApiModelProperty(value = "报表生成日期", name = "date")
private String date;
@ApiModelProperty(value = "该区域报表描述第一段", name = "description1")
private String description1;
@ApiModelProperty(value = "该区域报表描述第二段", name = "description2")
private String description2;
@ApiModelProperty(value = "报表中每个图表的内容列表", name = "reports")
private List<ReportContentRequest> reports;
}
@Data
@ApiModel("单个图表请求对象")
public class ReportContentRequest {
@ApiModelProperty(value = "报表中排列序号", name = "serial")
private Integer serial;
@ApiModelProperty(value = "单个图表标题", name = "title")
private String title;
@ApiModelProperty(value = "单个图表base64编码值", name = "base64")
private String base64;
@ApiModelProperty(value = "单个图表内容总结", name = "summary")
private String summary;
@ApiModelProperty(value = "该标题下存在多个报表", name = "children")
private List<ReportContentRequest> children;
}
6. 创建导出word工具类
该工具类是实现导出word功能的核心类,读取模板文件,格式化请求参数,填充模板生成word文档的功能都在此工具类完成
public class WordGeneratorUtils {
private static Configuration configuration = null;
private static Map<String, Template> allTemplates = null;
private static class FreemarkerTemplate {
public static final String POVERTY = "poverty";
}
static {
configuration = new Configuration(Configuration.VERSION_2_3_28);
configuration.setDefaultEncoding("utf-8");
configuration.setClassForTemplateLoading(WordGeneratorUtils.class, "/freemarker/template");
allTemplates = new HashMap();
try {
allTemplates.put(FreemarkerTemplate.POVERTY, configuration.getTemplate(FreemarkerTemplate.POVERTY + ".ftl"));
} catch (IOException e) {
e.printStackTrace();
throw new RuntimeException(e);
}
}
private WordGeneratorUtils() {
throw new AssertionError();
}
public static File createDoc(Map<String, String> dataMap) {
try {
String name = dataMap.get("title") + dataMap.get("date") + ".doc";
File f = new File(name);
Template t = allTemplates.get(dataMap.get("template"));
// 这个地方不能使用FileWriter因为需要指定编码类型否则生成的Word文档会因为有无法识别的编码而无法打开
Writer w = new OutputStreamWriter(new FileOutputStream(f), "utf-8");
t.process(dataMap, w);
w.close();
return f;
} catch (Exception ex) {
ex.printStackTrace();
throw new RuntimeException("生成word文档失败");
}
}
public static Map<String, String> parseToMap(ReportExportWordRequest request) {
Map<String, String> datas = new HashMap(32);
//主标题
datas.put("title", request.getTitle());
datas.put("date", request.getDate());
datas.put("watermark", request.getWatermark());
datas.put("description1", request.getDescription1());
datas.put("description2", request.getDescription2());
//遍历设置报表
List<ReportContentRequest> contents = request.getReports();
datas.put("template", request.getType());
for (ReportContentRequest c : contents) {
if (c.getChildren() == null || c.getChildren().size() == 0) {
//无子报表
datas.put("title_" + c.getSerial(), c.getTitle());
datas.put("base64_" + c.getSerial(), c.getBase64());
datas.put("summary_" + c.getSerial(), c.getSummary());
} else {
//有多个子报表
datas.put("title_" + c.getSerial(), c.getTitle());
for (ReportContentRequest subc : c.getChildren()) {
datas.put("title_" + c.getSerial() + "_" + subc.getSerial(), subc.getTitle());
datas.put("base64_" + c.getSerial() + "_" + subc.getSerial(), subc.getBase64());
datas.put("summary_" + c.getSerial() + "_" + subc.getSerial(), subc.getSummary());
}
}
}
return datas;
}
}
##7. 创建业务接口与实现类ReportService
接口类
public interface ReportService {
File exportWord(ReportExportWordRequest exportWordRequest);
}
ReportServiceImpl
实现类
@Service
public class ReportServiceImpl implements ReportService {
@Override
public File exportWord(ReportExportWordRequest exportWordRequest) {
//解析参数
Map<String, String> datas = WordGeneratorUtils.parseToMap(exportWordRequest);
//导出
File word = WordGeneratorUtils.createDoc(datas);
return word;
}
}
8. 创建Controller类
@Slf4j
@RestController
@RequestMapping("/api/v1/report")
public class ReportController {
@Autowired
private ReportService reportService;
@ApiOperation(value = "贫困人群综合分析报告导出word文档", notes = "贫困人群综合分析报告导出word文档")
@PostMapping("/poverty_export_word.ajax")
public void povertyExportWord(HttpServletRequest request, HttpServletResponse response,
@Valid @RequestBody ReportExportWordRequest exportWordRequest) {
File file = reportService.exportWord(exportWordRequest);
InputStream fin = null;
OutputStream out = null;
try {
// 调用工具类WordGeneratorUtils的createDoc方法生成Word文档
fin = new FileInputStream(file);
response.setCharacterEncoding("utf-8");
response.setContentType("application/msword");
// 设置浏览器以下载的方式处理该文件
// 设置文件名编码解决文件名乱码问题
response.addHeader("Content-Disposition", "attachment;filename=" + new String(file.getName().getBytes(), "iso-8859-1"));
out = response.getOutputStream();
byte[] buffer = new byte[512];
int bytesToRead = -1;
// 通过循环将读入的Word文件的内容输出到浏览器中
while ((bytesToRead = fin.read(buffer)) != -1) {
out.write(buffer, 0, bytesToRead);
}
} catch (Exception e) {
throw new RuntimeException("导出失败", e);
} finally {
try {
if (fin != null) {
fin.close();
}
if (out != null) {
out.close();
}
if (file != null) {
file.delete();
}
} catch (IOException e) {
throw new RuntimeException("导出失败", e);
}
}
}
}
##9. 创建spring boot 启动类与yml配置
启动类在前面已经创建,此处只贴出application.yml基本配置
server:
port: 8080
context-path: /zhuxl
##10. 创建swagger配置
@Configuration
@EnableSwagger2
public class SwaggerConfiguration {
@Bean
public Docket api() {
ParameterBuilder parameterBuilder = new ParameterBuilder();
parameterBuilder.name("Access-Token")
.description("令牌")
.modelRef(new ModelRef("string"))
.parameterType("header")
.required(false)
.build();
List<Parameter> parameters = new ArrayList<>();
parameters.add(parameterBuilder.build());
return new Docket(DocumentationType.SWAGGER_2).select()
.apis(RequestHandlerSelectors.any())
.paths(PathSelectors.regex("/api/v1/.*"))
.build()
.globalOperationParameters(parameters)
.apiInfo(apiInfo());
}
}
11. 运行,访问swagger页面测试
执行Applicaton类的main方法运行demo,访问swagger接口页面,在本demo中访问地址为:http://localhost:8080/zhuxl/swagger-ui.html
12. 构造参数测试获得报表word文件
构造json参数,点击try it out按钮,即可进行测试并将文件下载,由于请求参数中base64编码内容过于复杂,因此贴出的参数中图片base64编码省略
{
"unitLevel":"4",
"unitCode":"513429100000",
"type":"poverty",
"title":"XXXX地区贫困人群总体情况报告",
"watermark":"张三13800138000",
"date":"2018年6月",
"description1":"报告正文开始的第一部分增加对该区域整体描述,描述中加入总关注人群数等信息",
"description2":"报告正文开始的第一部分增加对该区域整体描述,描述中加入总关注人群数等信息,报告正文开始的第一部分增加对该区域整体描述,描述中加入总关注人群数等信息,报告正文开始的第一部分增加对该区域整体描述,描述中加入总关注人群数等信息,报告正文开始的第一部分增加对该区域整体描述,描述中加入总关注人群数等信息,报告正文开始的第一部分增加对该区域整体描述,描述中加入总关注人群数等信息,报告正文开始的第一部分增加对该行政区域整体描述,描述中加入总关注人群数等信息,报告正文开始的第一部分增加对该行政区域整体描述,描述中加入总关注人群数等信息,报告正文开始的第一部分增加对该行政区域整体描述,描述中加入总关注人群数等信息",
"reports":[
{
"serial":1,
"title":"一、贫困人口占比排名",
"base64":"xxxx",
"summary":"截止2018年6月,贫困人口占比最高的是XXX,占比达到60.8233%;占比最低的是XXXX,占比为11.6273%。",
"children":[]
},
{
"serial":2,
"title":"二、贫困人口关注等级分析",
"base64":"xxxxxxxx",
"summary":"截止2018年6月,总贫困人口中,一般关注等级0人,中度关注等级68156人,重点关注等级1人。三类人群分别占总人口的0%,34.2872%,0.0005%;占贫困总人口的0%,99.9985%,0.0014%。",
"children":[]
},
{
"serial":3,
"title":"三、致贫原因分析",
"base64":"xxxxx",
"summary":"截止2018年6月,贫困人口中,自身发展动力不足原因致贫的人数最多,占总贫困人口的37.0583%;其它原因致贫的人数最少,占总贫困人口的0.0161%。",
"children":[]
},
{
"serial":4,
"title":"四、贫困人群性别分析",
"base64":"xxxx",
"summary":"xxxxxxxxxxxxxxxxxxxxx",
"children":[]
},
{
"serial":5,
"title":"五、贫困人群年龄分析",
"base64":"xxxxx",
"summary":"",
"children":[]
},
{
"serial":6,
"title":"六、贫困人群学历分析",
"base64":"xxxxxx",
"summary":"",
"children":[]
},
{
"serial":7,
"title":"七、贫困人群民族分析",
"base64":"xxxxx",
"summary":"",
"children":[]
},
{
"serial":8,
"title":"八、贫困人群脱贫能力分析",
"base64":"",
"summary":"",
"children":[
{
"serial":1,
"title":"1、文化程度分析",
"base64":"xxxxx",
"summary":"截止2018年6月,贫困人口中,中度关注等级中的文盲或半文盲,学龄前学历人数最多,占总贫困人口的30.7232%;重度关注等级中的大专及以上学历人数最少,占总贫困人口的0%。"
},
{
"serial":2,
"title":"2、劳动能力分析",
"base64":"xxxxxx",
"summary":"截止2018年6月,贫困人口中,中度关注等级中的丧失劳动力劳动力人数最多,占总贫困人口的51.1466%;重度关注等级中的技能劳动力劳动力人数最少,占总贫困人口的0%。"
},
{
"serial":3,
"title":"3、健康情况分析",
"base64":"xxxxx",
"summary":"截止2018年6月,贫困人口中,中度关注等级中的健康健康状况人数最多,占总贫困人口的96.5653%;重度关注等级中的残疾健康状况人数最少,占总贫困人口的0%。"
}
]
},{
"serial":9,
"title":"九、资产和收入分析",
"base64":"",
"summary":"",
"children":[
{
"serial":1,
"title":"1、家庭收入分析",
"base64":"xxxxx",
"summary":"截止2018年6月,贫困人口中,中度关注等级中的5k-10k收入人数最多,占总贫困人口的9.0365%;重度关注等级中的15k以上收入人数最少,占总贫困人口的0%"
},
{
"serial":2,
"title":"2、房产情况分析",
"base64":"xxxxx",
"summary":"截止2018年6月,贫困人口中,中度关注等级中的房屋面积50-100房产人数最多,占总贫困人口的18.8565%;重度关注等级中的房屋面积100平以上房产人数最少,占总贫困人口的0%"
},
{
"serial":3,
"title":"3、耕地林地情况分析",
"base64":"xxxxx",
"summary":"截止2018年6月,贫困人口中,中度关注等级中拥有耕地面积5.32亩以上亩的人数最多,占总贫困人口的7.7556%;重度关注等级中拥有耕地面积5.32亩以上亩的人数最少,占总贫困人口的0%"
},
{
"serial":4,
"title":"4、新农合、养老保险情况分析",
"base64":"xxxx",
"summary":"截止2018年6月,贫困人口中,中度关注等级中办理已参加新农合保险的人数最多,占总贫困人口的99.9956%;重度关注等级中办理已办理养老保险保险的人数最少,占总贫困人口的0%"
}
]
},
{
"serial":10,
"title":"十、预脱贫分析",
"base64":"xxxx",
"summary":"截止2018年6月,预脱贫人口中,2020年预脱贫的人数最多,占总贫困人口的2.4134%",
"children":[]
},
{
"serial":11,
"title":"十一、生活状况分析",
"base64":"xxxx",
"summary":"截止2018年6月,贫困人口家庭中,没有实现卫生厕所的贫困家庭数量占比最高,占比为80.7526%",
"children":[]
}
]
}
##13. 打开文件验证
将swagger接口页面Response Body请求返回的doc文档下载并打开,效果图见文章顶部
#七、问题排查
- doc模板设计保存成xml模板文件占位符分离,如${title_1}可能被分离成$、title_、1、}或者${title_、1}或者其他情况
方案一:手动修改xml中被分离的占位符,但缺点是如果模板需要做一点改动,保存的xml又需要手动修改,增加无谓的工作量
方案二:将整个占位符的样式设置成一样,但事实上同样存在被分离的情况
方案三:该方案可完美解决占位符分离情况,避免修改doc模板保存时重复修改占位符,点击查看详细方案
#八、git clone
传送门:去star
git clone https://github.com/v5zhu/export-doc.git