POI生成Word文档

使用POI XWPF生成Word文档,引入POI:

<dependency>
    <groupId>org.apache.poi</groupId>
    <artifactId>poi-ooxml</artifactId>
    <version>5.0.0</version>
</dependency>

模板替换

项目中经常从Word模板生成文档,下面提供了替换文档内容的工具类。模版中要替换的内容以${key}标识,两个${key}间至少要有一个字符分隔。

package com.ezp.resumes.util;

import org.apache.poi.xwpf.usermodel.XWPFDocument;
import org.apache.poi.xwpf.usermodel.XWPFParagraph;
import org.apache.poi.xwpf.usermodel.XWPFRun;

import java.io.ByteArrayOutputStream;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.util.List;
import java.util.Map;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

public final class XWPFDocumentUtils {
    private static final String KEG_REGEX = "\\$\\{(\\w+)}";
    private static final Pattern KEY_PATTERN = Pattern.compile(KEG_REGEX);

    private XWPFDocumentUtils() {
    }

    /**
     * 从Word模板生成文档,替换其中的${key}
     *
     * @param templatePath   模板文件路径
     * @param fields 替换的键值对
     * @return byte[]
     * @throws IOException
     */
    public static byte[] generateDocument(String templatePath, Map<String, String> fields) throws IOException {
        try (XWPFDocument doc = new XWPFDocument(new FileInputStream(path))) {
            replaceDoc(doc, fields);

            return output(doc);
        }
    }

    private static void replaceDoc(XWPFDocument doc, Map<String, String> fields) {
        for (XWPFParagraph paragraph : doc.getParagraphs()) {
            if (!paragraph.getText().contains("${")) {
                continue;
            }

            replaceText(paragraph, fields);
        }
    }

    private static void replaceText(XWPFParagraph paragraph, Map<String, String> fields) {
        List<XWPFRun> runs = paragraph.getRuns();
        for (int i = 0; i < runs.size(); i++) {
            XWPFRun run = runs.get(i);
            String text = run.text();

            if (isContainsKey(text, runs, i)) {
                // 删除${之后的XWPFRun, 并将${部分和删除内容保存到StringBuilder中
                StringBuilder builder = new StringBuilder(text);
                while (!text.contains("}")) {
                    text = runs.get(i + 1).text();
                    builder.append(text);
                    paragraph.removeRun(i + 1);
                }
                text = builder.toString();

                // 替换内容
                String key = getKey(text);
                if (key != null && fields.containsKey(key)) {
                    String value = fields.get(key);
                    text = text.replaceFirst(KEG_REGEX, value == null ? "" : value);
                }
                run.setText(text, 0);
            }
        }
    }

    private static boolean isContainsKey(String text, List<XWPFRun> runs, int i) {
        return text.contains("${") || (text.contains("$") && runs.get(i + 1).text().startsWith("{"));
    }

    private static byte[] output(XWPFDocument doc) throws IOException {
        try (ByteArrayOutputStream out = new ByteArrayOutputStream()) {
            doc.write(out);
            return out.toByteArray();
        }
    }

    private static String getKey(String text) {
        Matcher matcher = KEY_PATTERN.matcher(text);
        return matcher.find() ? matcher.group(1) : null;
    }
}

POI常用参数

使用POI创建Word文档的常用参数:

XWPFParagraph paragraph = doc.createParagraph();
// 设置段落样式
paragraph.setAlignment(ParagraphAlignment.LEFT); // 左对齐
paragraph.setIndentationFirstLine(55); // 首行缩进0.1厘米
paragraph.setSpacingBeforeLines(50); // 段前0.5行
paragraph.setSpacingAfterLines(50); // 段后0.5行
paragraph.setSpacingBetween(1.25, LineSpacingRule.AUTO); // 1.25倍行距
CTP ctp = paragraph.getCTP();
CTPPr ctpPr = ctp.addNewPPr();
// 设置大纲级别2级
ctpPr.addNewOutlineLvl().setVal(BigInteger.valueOf(1));
// 设置底纹样式
CTShd shd = ctpPr.addNewShd();
shd.setFill("dbe5f1"); // 填充色
shd.setVal(STShd.CLEAR); // 图案样式:清除
shd.setColor("auto"); // 图案颜色:自动

// 设置段落文本
XWPFRun run = paragraph.createRun();
run.setText("Hello POI");
run.setFontFamily("宋体"); // 字体
run.setFontSize(10); // 字号
run.setCharacterSpacing(15); // 加宽字体,0.75磅
run.setCapitalized(true); // 大写
run.setBold(true);  // 粗体

Spring Boot Rest API

Rest API

下载文档API使用@GetMapping,并指定MediaType为APPLICATION_OCTET_STREAM_VALUE。调用generateDocument()方法生成word文档,若要指定文件名称,返回类型要使用ResponseEntity并增加"Content-Disposition" header,否则可以直接返回byte[]。

@GetMapping(value ="/api/doc/{heroName}", produces = MediaType.APPLICATION_OCTET_STREAM_VALUE)
public ResponseEntity<byte[]> downloadDocument(@PathVariable String heroName) {
    try {
        Map<String, String> fields = new HashMap<>();
        fields.put("hero_name", heroName);
        fields.put("create_date", "2019年6月");
        byte[] bytes = XWPFDocumentUtils.generateDocument("template/hero.docx", fields);
        HttpHeaders headers = new HttpHeaders();
				String filename = "你好POI.docx";
        headers.add("Content-Disposition", "attachment; filename=" + encode(filename));
        return ResponseEntity.ok().headers(headers).body(bytes);
    } catch (Exception e) {
        throw new XWPFDocumentException(e.getMessage());
    }
}

// 中文文件名称需要使用URL编码
private String encode(String filename) {
    try {
        return URLEncoder.encode(filename, "UTF-8");
    } catch (UnsupportedEncodingException e) {
        return filename;
    }
}

CORS

配置CORS的ExposedHeaders,否则前台不能读取"Content-Disposition":

/**
 * CORS配置
 */
@Configuration
@Slf4j
public class CorsConfig {
    @Bean
    public WebMvcConfigurer corsConfigurer() {
        return new WebMvcConfigurer() {
            @Override
            public void addCorsMappings(CorsRegistry registry) {
                registry.addMapping("/api/**")
                        .allowedOrigins("*")
                        .allowedMethods("*")
                        .allowedHeaders("Accept", "Accept-Encoding", "Accept-Language", "Authorization", "Connection", "Content-Type", "Host", "Origin", "Referer", "User-Agent", "X-Requested-With")
                        .exposedHeaders("Content-Disposition");
            }
        };
    }
}

Test

测试使用exchange方法,设置header APPLICATION_OCTET_STREAM:

import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.web.client.TestRestTemplate;
import org.springframework.http.*;

import java.util.Arrays;

import static org.assertj.core.api.Assertions.assertThat;

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
public class HeroesApplicationTests {

    @Autowired
    private TestRestTemplate restTemplate;

    @Test
    public void getDocumentSuccess() {
        HttpHeaders headers = new HttpHeaders();
        headers.setAccept(Arrays.asList(MediaType.APPLICATION_OCTET_STREAM));
        HttpEntity<String> entity = new HttpEntity<>(headers);

        ResponseEntity<byte[]> response = restTemplate.exchange("/api/doc/jason", HttpMethod.GET, entity, byte[].class);
        assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
    }
}

Angular下载文档

可以直接使用链接访问REST URL下载文档,若项目启用了JWT Token验证,则必须使用HttpClient的get方法。 本文使用了FileSaver.js保存文档,开始之前先安装:

npm install --save file-saver

然后在tsconfig.json中添加:

"paths": {
  "file-saver": [
    "node_modules/file-saver/dist/FileSaver.js"
  ]
}

下载文档,使用API提供的文件名称:

import * as fs from 'file-saver';

downloadDocument() {
  this.httpClient.get('yourUrl', {observe: 'response', responseType: 'blob'}).subscribe(response => {
    fs.saveAs(response.body, this.getFilename(response.headers));
  });
}

private getFilename(headers: HttpHeaders): string {
  const disposition = headers.get('Content-Disposition');
  if (!disposition || disposition.indexOf('filename=') < 0) {
    return '';
  }

  const filename = disposition.split('=')[1];
  return decodeURI(filename);
}

或前台自定义名称:

downloadDocument() {
  this.httpClient.get('yourUrl', {responseType: 'blob'}).subscribe(data => {
    fs.saveAs(data, 'yourFilename');
  });
}

参考文档

Excel File – Download from SpringBoot RestAPI + Apache POI + MySQL Apache POI Word Tutorial