Skip to content

Commit 20004ad

Browse files
committed
feat(easydocx): sync EasyDocx API from 3.0.x (JDK 17 adaptation)
Backport the EasyExcel/easyodf-style API to the 2.0.x line: - @DocxField/@DocxIgnore annotations, EasyDocx facade, DocxWriterBuilder/ DocxReaderBuilder, DocxReadListener, DocxFields POJO-to-Map - Adapted: no DocxTemplates/DocxMode in 2.0.x (3.0.x-only), builders use new WordprocessingMLDocxTemplate() directly 12 new EasyDocx tests green; full core verify BUILD SUCCESS (350 tests).
1 parent e6470d3 commit 20004ad

13 files changed

Lines changed: 569 additions & 0 deletions

File tree

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
package io.github.easy4j.doc.annotation;
2+
3+
import java.lang.annotation.ElementType;
4+
import java.lang.annotation.Retention;
5+
import java.lang.annotation.RetentionPolicy;
6+
import java.lang.annotation.Target;
7+
8+
/**
9+
* 标注 POJO 字段到文档占位符的映射(类 EasyExcel {@code @ExcelProperty} /
10+
* easyodf {@code @OFDProperty})。value 为占位符名(默认取字段名,渲染时由
11+
* 模板包装成 {@code ${name}});format 支持 Date/Number 格式化。
12+
*/
13+
@Target(ElementType.FIELD)
14+
@Retention(RetentionPolicy.RUNTIME)
15+
public @interface DocxField {
16+
17+
/** 占位符名(默认取字段名)。 */
18+
String value() default "";
19+
20+
/** 日期/数字格式化模式(如 "yyyy-MM-dd")。 */
21+
String format() default "";
22+
23+
/** 是否忽略该字段(等价 {@link DocxIgnore})。 */
24+
boolean ignore() default false;
25+
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
package io.github.easy4j.doc.annotation;
2+
3+
import java.lang.annotation.ElementType;
4+
import java.lang.annotation.Retention;
5+
import java.lang.annotation.RetentionPolicy;
6+
import java.lang.annotation.Target;
7+
8+
/** 标注转换时忽略的字段(类 EasyExcel {@code @ExcelIgnore})。 */
9+
@Target(ElementType.FIELD)
10+
@Retention(RetentionPolicy.RUNTIME)
11+
public @interface DocxIgnore {
12+
}
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
package io.github.easy4j.doc.easy;
2+
3+
import java.lang.reflect.Field;
4+
import java.text.SimpleDateFormat;
5+
import java.util.Date;
6+
import java.util.HashMap;
7+
import java.util.Map;
8+
9+
import io.github.easy4j.doc.annotation.DocxField;
10+
import io.github.easy4j.doc.annotation.DocxIgnore;
11+
12+
/**
13+
* 将 POJO 按 {@link DocxField}/{@link DocxIgnore} 注解转换为文档变量 Map
14+
* (对齐 easyodf 的 OFDReflectionUtils)。字段值映射到占位符名,默认占位符
15+
* 为字段名(渲染时由模板包装成 ${name}),支持 format 日期格式化。
16+
*/
17+
public final class DocxFields {
18+
19+
private DocxFields() {
20+
}
21+
22+
/**
23+
* 提取 bean 中所有可渲染字段为 占位符名 → 值 的 Map。
24+
* @param bean 模型对象;null 返回空 Map
25+
* @return 占位符名 → 值
26+
*/
27+
public static Map<String, Object> from(Object bean) {
28+
Map<String, Object> map = new HashMap<String, Object>();
29+
if (bean == null) {
30+
return map;
31+
}
32+
for (Field field : bean.getClass().getDeclaredFields()) {
33+
if (field.isAnnotationPresent(DocxIgnore.class)) {
34+
continue;
35+
}
36+
DocxField df = field.getAnnotation(DocxField.class);
37+
if (df != null && df.ignore()) {
38+
continue;
39+
}
40+
try {
41+
field.setAccessible(true);
42+
Object value = field.get(bean);
43+
String placeholder = (df != null && !df.value().isEmpty())
44+
? df.value()
45+
: field.getName();
46+
map.put(placeholder, formatValue(value, df != null ? df.format() : ""));
47+
} catch (IllegalAccessException e) {
48+
// 反射不可达:跳过该字段,不阻断整体转换
49+
}
50+
}
51+
return map;
52+
}
53+
54+
private static Object formatValue(Object value, String format) {
55+
if (value instanceof Date && format != null && !format.isEmpty()) {
56+
return new SimpleDateFormat(format).format((Date) value);
57+
}
58+
return value;
59+
}
60+
}
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
package io.github.easy4j.doc.easy;
2+
3+
import java.util.Map;
4+
5+
/**
6+
* 模板读取监听器(对齐 EasyExcel {@code ReadListener} / easyodf
7+
* {@code OFDReadListener})。每次解析出一个数据单元时回调 {@link #invoke},
8+
* 全部解析完回调 {@link #doAfterAllAnalysed}。
9+
*
10+
* @param <T> 数据模型类型
11+
*/
12+
public interface DocxReadListener<T> {
13+
14+
/**
15+
* 解析到一条数据(占位符名 → 值)时回调。
16+
* @param data 当前数据实例(由模型无参构造创建)
17+
* @param values 解析出的占位符名 → 值映射
18+
*/
19+
void invoke(T data, Map<String, String> values);
20+
21+
/** 全部解析完成后回调;默认空实现。 */
22+
default void doAfterAllAnalysed() {
23+
}
24+
}
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
package io.github.easy4j.doc.easy;
2+
3+
import java.io.File;
4+
import java.util.HashMap;
5+
import java.util.Map;
6+
7+
import org.docx4j.openpackaging.packages.WordprocessingMLPackage;
8+
9+
/**
10+
* docx 模板读取 Builder(对齐 easyodf {@code OFDReaderBuilder} /
11+
* EasyExcel {@code ExcelReaderBuilder}):解析模板占位符,回调
12+
* {@link DocxReadListener}。
13+
*
14+
* @param <T> 模型类型
15+
*/
16+
public final class DocxReaderBuilder<T> {
17+
18+
private final File templateFile;
19+
private final Class<T> model;
20+
private final DocxReadListener<T> listener;
21+
22+
public DocxReaderBuilder(File templateFile, Class<T> model, DocxReadListener<T> listener) {
23+
this.templateFile = templateFile;
24+
this.model = model;
25+
this.listener = listener;
26+
}
27+
28+
/**
29+
* 解析模板(占位符 → 值),回调 listener。模板缺失时静默返回(薄封装语义)。
30+
*/
31+
public void doRead() {
32+
if (templateFile == null || !templateFile.exists()) {
33+
return;
34+
}
35+
try {
36+
WordprocessingMLPackage pkg = new io.github.easy4j.doc.WordprocessingMLDocxTemplate()
37+
.process(templateFile, new HashMap<String, Object>());
38+
String xml = pkg.getMainDocumentPart().getXML();
39+
Map<String, String> values = extractPlaceholders(xml);
40+
T data = newInstance();
41+
if (listener != null && data != null) {
42+
listener.invoke(data, values);
43+
listener.doAfterAllAnalysed();
44+
}
45+
} catch (Exception e) {
46+
// 读取失败不抛出(薄封装语义);调用方可自行判断
47+
}
48+
}
49+
50+
private T newInstance() {
51+
try {
52+
return model.getDeclaredConstructor().newInstance();
53+
} catch (Exception e) {
54+
return null;
55+
}
56+
}
57+
58+
/** 提取文档 XML 中所有 ${name} 占位符(值为空串,供监听器识别键集合)。 */
59+
private Map<String, String> extractPlaceholders(String xml) {
60+
Map<String, String> values = new HashMap<String, String>();
61+
String start = "${";
62+
String end = "}";
63+
int from = 0;
64+
int i = xml.indexOf(start, from);
65+
while (i >= 0) {
66+
int j = xml.indexOf(end, i + start.length());
67+
if (j > i) {
68+
String key = xml.substring(i + start.length(), j);
69+
if (!key.isEmpty()) {
70+
values.put(key, "");
71+
}
72+
from = j + end.length();
73+
} else {
74+
from = i + start.length();
75+
}
76+
i = xml.indexOf(start, from);
77+
}
78+
return values;
79+
}
80+
}
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
package io.github.easy4j.doc.easy;
2+
3+
import java.io.File;
4+
import java.util.HashMap;
5+
import java.util.Map;
6+
7+
import org.docx4j.openpackaging.packages.WordprocessingMLPackage;
8+
9+
import io.github.easy4j.doc.WordprocessingMLTemplate;
10+
11+
/**
12+
* docx 模板渲染链式 Builder(对齐 easyodf {@code OFDWriterBuilder} /
13+
* EasyExcel {@code ExcelWriterBuilder})。docx 无 sheet,中间层用
14+
* {@link #document(String)}(Document 概念)标识渲染目标;单文档时 document
15+
* 可省略。process 内部 POJO → Map → 现有模板管线。
16+
*
17+
* @param <T> 模型类型
18+
*/
19+
public final class DocxWriterBuilder<T> {
20+
21+
private final File templateFile;
22+
private final Class<T> model;
23+
24+
public DocxWriterBuilder(File templateFile, Class<T> model) {
25+
this.templateFile = templateFile;
26+
this.model = model;
27+
}
28+
29+
/**
30+
* docx 语义中间层(对齐 EasyExcel sheet 的位置):标识文档/模板实例。
31+
* 单文档渲染时不改变管线行为;多文档批量场景由调用方多次 process。
32+
*/
33+
public DocxWriterBuilder<T> document(String name) {
34+
return this;
35+
}
36+
37+
/** POJO 模型渲染:@DocxField 注解 → Map → 现有模板管线。 */
38+
public WordprocessingMLPackage process(T data) throws Exception {
39+
return process(DocxFields.from(data));
40+
}
41+
42+
/** 原始 Map 渲染(兼容现有 API 的变量注入)。 */
43+
public WordprocessingMLPackage process(Map<String, Object> vars) throws Exception {
44+
WordprocessingMLTemplate template = new io.github.easy4j.doc.WordprocessingMLDocxTemplate();
45+
Map<String, Object> effective = new HashMap<String, Object>(vars);
46+
return template.process(templateFile, effective);
47+
}
48+
}
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
package io.github.easy4j.doc.easy;
2+
3+
import java.io.File;
4+
5+
/**
6+
* 类 EasyExcel / easyodf 的 easydoc 静态门面:链式构建 docx 模板渲染与读取。
7+
* 薄封装:内部委托 {@code DocxTemplates} + {@code WordprocessingMLTemplate}
8+
* 管线,不替代引擎;高级场景(SAX 细节、XHTML 导入)仍用现有 API。
9+
*/
10+
public final class EasyDocx {
11+
12+
private EasyDocx() {
13+
}
14+
15+
public static <T> DocxWriterBuilder<T> write(String templatePath, Class<T> model) {
16+
return new DocxWriterBuilder<T>(new File(templatePath), model);
17+
}
18+
19+
public static <T> DocxWriterBuilder<T> write(File templateFile, Class<T> model) {
20+
return new DocxWriterBuilder<T>(templateFile, model);
21+
}
22+
23+
public static <T> DocxReaderBuilder<T> read(String templatePath, Class<T> model,
24+
DocxReadListener<T> listener) {
25+
return new DocxReaderBuilder<T>(new File(templatePath), model, listener);
26+
}
27+
28+
public static <T> DocxReaderBuilder<T> read(File templateFile, Class<T> model,
29+
DocxReadListener<T> listener) {
30+
return new DocxReaderBuilder<T>(templateFile, model, listener);
31+
}
32+
}
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
package io.github.easy4j.doc.annotation;
2+
3+
import static org.junit.jupiter.api.Assertions.assertEquals;
4+
import static org.junit.jupiter.api.Assertions.assertNotNull;
5+
import static org.junit.jupiter.api.Assertions.assertTrue;
6+
7+
import java.lang.reflect.Field;
8+
9+
import org.junit.jupiter.api.Test;
10+
11+
class DocxFieldAnnotationTest {
12+
13+
static class Model {
14+
@DocxField("partyName")
15+
private String name;
16+
@DocxField(value = "signDate", format = "yyyy-MM-dd")
17+
private java.util.Date date;
18+
@DocxIgnore
19+
private String internal;
20+
}
21+
22+
@Test
23+
void annotationsAreRuntimeVisibleAndCarryValues() throws Exception {
24+
Field name = Model.class.getDeclaredField("name");
25+
DocxField df = name.getAnnotation(DocxField.class);
26+
assertNotNull(df, "@DocxField must be present");
27+
assertEquals("partyName", df.value());
28+
assertEquals("", df.format());
29+
assertEquals(false, df.ignore());
30+
31+
Field date = Model.class.getDeclaredField("date");
32+
assertEquals("yyyy-MM-dd", date.getAnnotation(DocxField.class).format());
33+
34+
Field internal = Model.class.getDeclaredField("internal");
35+
assertTrue(internal.getAnnotation(DocxIgnore.class) != null,
36+
"@DocxIgnore must be present");
37+
}
38+
}
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
package io.github.easy4j.doc.easy;
2+
3+
import static org.junit.jupiter.api.Assertions.assertEquals;
4+
import static org.junit.jupiter.api.Assertions.assertFalse;
5+
import static org.junit.jupiter.api.Assertions.assertTrue;
6+
7+
import java.util.Date;
8+
import java.util.Map;
9+
import java.util.TimeZone;
10+
11+
import org.junit.jupiter.api.AfterAll;
12+
import org.junit.jupiter.api.BeforeAll;
13+
import org.junit.jupiter.api.Test;
14+
15+
import io.github.easy4j.doc.annotation.DocxField;
16+
import io.github.easy4j.doc.annotation.DocxIgnore;
17+
18+
class DocxFieldsTest {
19+
20+
private static TimeZone original;
21+
22+
@BeforeAll
23+
static void fixTimeZone() {
24+
original = TimeZone.getDefault();
25+
TimeZone.setDefault(TimeZone.getTimeZone("UTC"));
26+
}
27+
28+
@AfterAll
29+
static void restoreTimeZone() {
30+
TimeZone.setDefault(original);
31+
}
32+
33+
static class Contract {
34+
@DocxField("partyName")
35+
private String name = "ACME";
36+
@DocxField(value = "signDate", format = "yyyy-MM-dd")
37+
private Date date = new Date(1700000000000L);
38+
@DocxField
39+
private Integer amount = 100;
40+
@DocxIgnore
41+
private String internal = "secret";
42+
}
43+
44+
@Test
45+
void fromMapsAnnotatedFieldsToPlaceholders() {
46+
Map<String, Object> map = DocxFields.from(new Contract());
47+
assertEquals("ACME", map.get("partyName"));
48+
assertEquals("2023-11-14", map.get("signDate"), "format must apply to Date");
49+
assertEquals(100, map.get("amount"), "unannotated value defaults to field name");
50+
assertFalse(map.containsKey("internal"), "@DocxIgnore fields must be skipped");
51+
assertFalse(map.containsKey("name"), "raw field name must not appear when @DocxField overrides");
52+
}
53+
54+
@Test
55+
void fromHandlesNullBeanAndNullValues() {
56+
assertTrue(DocxFields.from(null).isEmpty(), "null bean yields empty map");
57+
Map<String, Object> m = DocxFields.from(new Object() {
58+
@DocxField("x")
59+
private String v = null;
60+
});
61+
assertTrue(m.containsKey("x"), "null value still appears with its placeholder key");
62+
assertEquals(null, m.get("x"));
63+
}
64+
}

0 commit comments

Comments
 (0)