一、引言:为什么需要这样一个组件?
业务背景
之前工作中,我需要为储存细胞的用户自动生成储存证书。UI只提供了原型图和图片素材,我需要按照原型样式,将数据动态填充,生成单页PDF。
核心挑战是:证书上的文字行数不固定(例如细胞数量可变,字段可能为空),页面高度需要自适应。
模板中包含固定元素(顶部/侧面/底部图片、证书标题)和动态区域(细胞列表,数量可变),证书高度不确定。
常规方案的痛点
常规的单模板做法(硬编码坐标、逐个元素绘制)在处理固定模板时确实简单直接。但它的核心问题不是“能不能实现功能”,而是:
1、模板复用成本高: 当时我需要支持两套不同的证书模板。如果按单模板思路,每套模板的坐标、样式都要写一套独立的绘制代码。模板越多,代码越臃肿,维护成本成倍增长。
2、布局与业务强耦合: 布局逻辑(在哪画、用什么字体)和业务代码(取哪个字段)揉在一起。调整布局可能影响业务,业务变更也可能破坏布局。
3、扩展性差: 新需求(如加背景、侧边框拉伸)必须修改核心绘制代码,容易引入新Bug。
我需要一个面向多模板、可扩展的方案让布局配置和业务代码解耦,而不是每来一个新模板就复制粘贴改一遍。做到:
-
一套引擎,多模板复用:新增模板只加配置,不改核心代码
-
布局与业务分离:字段只管数据,注解管展示
-
扩展点机制:背景、边框等特殊需求不侵入核心流程
二、整体设计思路
不依赖现成模板引擎,而是自研一套流式布局方案
现成的模板引擎主要有这几种:HTML转PDF、PDF表单填充、报表引擎。它们各有局限——HTML转PDF受CSS支持限制,表单填充只能做固定布局,报表引擎配置复杂且偏向数据分析场景。 而我主要面对的是证书场景:固定框架加动态内容,文字换行后高度自适应,多个列表垂直堆叠。现有的方案要么布局不可控,要么模板维护成本太高。 所以我选择自己用Java代码实现:用注解控制布局和样式,再结合反射动态计算高度和坐标。所有布局信息都内嵌在Java类里,不依赖外部模板文件本身。换模板只需要新增模版类并添加注解,不用动引擎的核心代码。
核心思想
- 先测量: 提前计算内容总高度。因为PDF创建页面时需要指定高度,提前算好后,后续绘制只需关注坐标,不用再担心页面放不放得下。
- 再绘制: 高度计算出来后,固定元素直接根据已有的文字坐标、字体字号、颜色;图片根据坐标、宽高绘制即可。如果是列表元素,列表元素用游标逐个累加,动态计算实际位置后再绘制。
布局模型
目前能处理的是块级元素 + 垂直堆叠。模板中的固定元素位置完全不变;其余可变元素只能是列表,并且列表内部、列表之间都是垂直排布。例如证书中既有细胞列表又有底部落款信息,那就可以定义两个列表,根据初始坐标和间距动态计算位置并绘制。
整体流程图
核心流程:
1、模板定义层: 普通 Java 类通过注解声明字段的位置、字体、样式,以及是否为列表。 2、引擎核心层: 分两阶段执行——测算阶段先测量内容总高度,绘制阶段再用游标推进绘制。 3、扩展点层: 前置处理用于自定义背景等,后置处理用于二次加工。 4、输出层: 生成最终 PDF 文件,支持单页长文档或自动分页。
三、注解驱动的配置方案
3.1 为什么用注解
- 坐标、文字字体、字号、颜色、图片宽高等布局信息。每个元素可能都不一样,不用注解只能硬编码,然后循环逐个填充。代码可读性差、可维护性低。
- 如果使用注解,就可以将布局信息和模版、填充内容的每个字段关联,调整某个元素的位置,直接调整该字段对应的注解参数即可。同时新增模板时,只需新建一个模板类即可### 3.2 核心注解
注: 以下注解涉及的所有坐标、尺寸的单位均为毫米,我在引擎内部会转换为pt点,使用者无需关注具体细节。
- @Position: 标记普通字段,定义 X、Y 坐标、文字固定开头、居中类型、底边距。
@Api("元素坐标,单位mm")
@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.FIELD})
@Documented
public @interface Position {
float positionX() default -1;//左边距,也就是距离pdf最左侧长度,单位mm
float positionY() default -1;//上边距
String title() default "";//文字的固定字段,例如“姓名:张三”里的“姓名:”
AlignEnum alignType() default AlignEnum.DEFAULT;//居中类型,HORIZONTAL-横向居中。VERTICAL-纵向。DEFAULT-原始坐标。SELF-自定义,引擎不绘制该元素。
float marginBottom() default 4.5f;//底边距
}
- @Font: 标记文本字段,表示文本的字体、颜色、字号。
@Api(tags = "字体设置")
@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.FIELD})
@Documented
public @interface Font {
FontTypeEnum fontType() default FontTypeEnum.MICROSOFT_YAHEI;//字体。默认微软雅黑
int fontSize();//字号
String color() default "#000000";//字体颜色的16进制字符串表示,用于转为Color对象。默认黑色
}
- @ImageStyle: 标记图片字段,表示图片的宽高
@Api(tags = "图片样式")
@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.FIELD})
@Documented
public @interface ImageStyle {
float width() default 0;
float height() default 0;
}
- @ModelSize、@PdfList:分别表示模版尺寸、标记该字段为需要填充进PDF的List,这里就不列举代码了。
3.3 模版类示例
我自己根据模版创建了一个证书模版java类CertiModel.java和内部需要放置的细胞TestCell.java,下面是分别它们的一部分:
- CertiModel.java:
@Data
@ModelSize(height = 240)
@ApiModel(description = "证书数据模型")
public class CertiModel {
@Position(positionX = 2, positionY = 0)
@ImageStyle(width = 206, height = 20)
@ApiModelProperty("证书顶部图片url")
private String topImg;
@Position(positionX = 123, positionY = 225, alignType = AlignEnum.SELF)
@Font(fontSize = 16)
@ApiModelProperty("组织名称")
private String orgName;
@Position(positionY = 70, alignType = AlignEnum.HORIZONTAL)
@Font(fontType = FontTypeEnum.HEITI, fontSize = 32)
@ApiModelProperty("证书标题")
private String certiTitle;
@ApiModelProperty("细胞列表")
@Position(positionY = 113)
@PdfList
List<TestCell> cellList;
}
- TestCell.java:
@Api(tags = "测试用细胞")
@Data
@ApiModel(description = "测试用细胞信息")
public class TestCell {
private String repoName;
@Position(positionY = 113, title = "细胞名称:", alignType = AlignEnum.HORIZONTAL)
@Font(fontSize = 24, fontType = FontTypeEnum.MICROSOFT_YAHEI)
private String cellName;
@Position(positionY = 126, title = "存储日期:", alignType = AlignEnum.HORIZONTAL)
@Font(fontSize = 18)
private String cellDate;
@Position(positionY = 137, title = "存储方式:", alignType = AlignEnum.HORIZONTAL)
@Font(fontSize = 12, fontType = FontTypeEnum.SONGTI, color = "#FF0000")
private String cellWay;
}
四、核心难点与解决方案
4.1 文字自动换行(按字符数量精确换行)
- 要求: 证书内文字不能超出页面宽度,需要按阈值换行,并保存换行后的字符串数组。
- 问题: 汉字、数字、英文字母宽度不一致,不同字体、字号宽度也不同。单纯按“字符数 × 平均宽度”计算不准,误差叠加后换行会错位。
- 解决方案: 逐个字符累加实际宽度。单个字符宽度直接用 PDFBox API 获取。超过阈值时断开,将该行字符串放入换行结果列表。
- (小优化)字符串缓存: 为用“原串 + 字体名 + 字号”作为 key,换行结果 List 作为 value,存入 HashMap。相同原串、字体、字号的文本复用结果,避免重复计算。
- 代码片段:
while (start < len) {
int end = start;
float currentWidth = 0f;
// 累加每个字符的宽度
while (end < len) {
char c = text.charAt(end);
float charWidth = pdFont.getStringWidth(String.valueOf(c)) / 1000f * font.fontSize();
// 如果加上当前字符超出最大宽度,换行
if (currentWidth + charWidth > getFountPt(maxWidthMM)) {
break;
}
currentWidth += charWidth;
end++;
}
// 避免死循环:如果一个字符都放不下,强制截取一个字符
if (start == end) {
end = start + 1;
}
lines.add(text.substring(start, end));
start = end;
}
// 3. 存入缓存,Key = text + "|" + fontName + "|" + fontSize
strs.put(key, lines);
4.2 动态页面高度(先测量,后绘制)
不可变内容: 占据高度完全不变的元素,包括固定空白。 可变内容: 可变部分是指占据高度随数据变化(如细胞列表)。
- 流程
- 计算不可变高度:遍历模板中所有可变部分,求其初始高度(无需获取字段值,也不累加计算)。做法:取可变部分最低点与最高点的差值之和。这种计算方式不关心初始模板是否有换行。
- 不可变高度white = 模版初始总高度 - 可变部分初始高度
- 部分代码 :
/**
* 获取可变行元素的默认初始高度,也就是模版中的列表所在类中,所有需要填充的文本初始的行总高度。
*/
public static float getItemLength(Class<?> cls) {
//yMin指左上边界,yMax指左下边界(包含底边距)
float yMin = Float.MAX_VALUE, yMax = 0, itemMargin;
//反射逻辑
Field[] declaredFields = cls.getDeclaredFields();
for (Field field : declaredFields) {
if (field.isAnnotationPresent(Position.class) && field.isAnnotationPresent(Font.class)) {
field.setAccessible(true);
Position position = field.getAnnotation(Position.class);
Font font = field.getAnnotation(Font.class);
int fontSize = font.fontSize();
float fontHeight = getFontMM(fontSize);
itemMargin = position.marginBottom();
minY = Math.min(minY, position.positionY());
yMin = Math.min(yMin, position.positionY());
//最低点也是y最大时,需要算上文字高度和底边距
yMax = Math.max(yMax, position.positionY() + fontHeight + itemMargin);
}
}
//0表示如果某个列表漏加注解就不计算,跳过。
return yMin == Float.MAX_VALUE ? 0 : yMax - yMin;
}
- 计算实际高度: 最终高度 = white + 可变部分实际高度。 可变部分实际高度通过循环累加每个可变元素的高度得到。遍历时,需要换行的字符串先拆分成多行,再用 行数 × (字体高度 + 底边距) 计算实际高度。若某字段或整个列表都为空,则高度不计算(置为0)。这是实现动态页面高度的核心。
上述两步计算实际上可以放在同一个循环里完成,部分代码如下:
Type genericType = field.getGenericType();
if (genericType instanceof ParameterizedType) {
field.setAccessible(true);
List<?> list = (List<?>) field.get(model);
//直接使用该列表获取实际占据的高度
sum += getListLength(list, field);
ParameterizedType pt = (ParameterizedType) genericType;
Type[] actualTypes = pt.getActualTypeArguments();
// 取第一个泛型参数,即 List<T> 中的 T
Class<?> elementClass = (Class<?>) actualTypes[0];
//这里计算模版中的可变部分高度,white初始为模版页面高度
white -= getItemLength(elementClass);
}
- 可变部分实际高度 计算核心逻辑:
for (Object o : list) {
Field[] fields = o.getClass().getDeclaredFields();
for (Field field : fields) {
field.setAccessible(true);
if (field.isAnnotationPresent(Position.class) && field.isAnnotationPresent(Font.class)
&& StringUtils.isNotBlank((String) field.get(o))) {
Position position = field.getAnnotation(Position.class);
Font font = field.getAnnotation(Font.class);
String str = position.title() + field.get(o);
//将字符串、字体、字号拼接作为key。便于缓存换行结果
String key = str + "|" + font.fontType() + "|" + font.fontSize();
int fontSize = font.fontSize();
//换行,换行的宽度阈值设置为180mm
splitString(str, key, font, 180);
//累加时需要用换行后字符串数量 * (字符高度+底边距)
len += strs.get(key).size() * (getFontMM(fontSize) + position.marginBottom());
}
}
//如果所有元素都为空,就不加底边距
if (len != 0) {
len += listMarginButton;
}
}
- 页面创建 直接使用上面计算出的总高度(宽度从注解读取)创建 PDF 页面。
- 绘制元素
字体加载、页面创建、内容流等前置操作不展开。绘制分两部分:
有了前面的准备工作,实际上绘制元素步骤相对来说较为简单。具体的绘制元素分为两部分,可变部分绘制和不可变部分绘制。
-
不可变部分绘制: 不可变部分:直接按注解中的坐标、值、图片路径及宽高绘制(需处理居中对齐)。
-
可变部分绘制: 先确定绘制起点(可变区域最低点),用游标逐列表绘制。换行字符串直接取缓存的结果,逐行绘制。
绘制元素的核心代码如下:
-
String value = (String) declaredField.get(o);
String key = position.title() + value + "|" + font.fontType() + "|" + font.fontSize();
List<String> strings = strs.get(key);
//获取字体
PDFont pdFont = fontConfig.getPDFont(font.fontType());
for (String string : strings) {
//每个字符串都要根据对齐方式计算实际左边距
xMM = getXMMbyPosition(position, font, pdFont, string);
if (!position.alignType().equals(AlignEnum.VERTICAL)) {
//游标位置默认文字左上角,而绘制起点是左下角,需要偏移。
yMM = moveY + getFontMM(font.fontSize());
//游标偏移,加上底边距和字体高度
moveY += position.marginBottom() + getFontMM(font.fontSize());
} else {
//垂直居中,重新计算y坐标,游标无需移动
yMM = (pageHeight - getFontMM(font.fontSize())) / 2;
}
drawText(contentStream, fontConfig, string, font, xMM * ptConvert, yMM * ptConvert);
}
4.3 空字段自动跳过(不占页面空间)
测量和绘制时判断字段值是否为 null 或空字符串,为空则跳过累加和绘制。注意:如果某个列表所有字段全为空,底边距也要跳过,不累加高度。
4.4 多列表垂直堆叠
支持一个模板内多个 @PdfList 字段,引擎按字段声明顺序依次渲染。
如需自定义顺序,可在注解中增加 order 字段,反射获取所有字段后按 order 排序绘制。目前未实现,留待后续扩展。
五、遇到的两个典型坑
5.1 PDFBox 版本过低导致乱码
- 问题: 字体已正确加载,也包含中文字形,能正常打开,但生成的 PDF 中所有汉字都变成方框。排查发现是中文字形没有被正确映射到 Unicode 码点。新版 PDFBox 的字体加载方法已修复此问题,因此确定是版本过低。
- 解决: 升级 PDFBox 到最新版,字体加载方法改用 PDType0Font.load(),问题解决。
5.2 毫米与点的转换精度丢失
-
问题: 有两个问题。首先,证书生成后其他位置正常,但右边框图片与页面右边缘之间始终有一丝白边;底边框最初正常,但调整细胞数量后,底部也出现了一丝白边。排查发现,1mm = 2.834645...pt(无限不循环小数),多次毫米与点相互转换导致误差累积,造成位置细微偏移。
-
解决: 统一内部计算全部使用毫米,仅在最后调用 PDFBox 绘制时转换为点。注解中的位置、宽度等对外只暴露毫米,并在关键位置做压边微调。
六、引擎的定位与边界
6.1、能做什么:
在传入的模版上绘制新内容
- 支持前置处理:调用方先创建带背景/边框的 PDF,引擎在其上绘制内容
样式完全固定的模版PDF
- 绝对坐标布局,所有字段位置固定
- 内容直接填充,不换行、不动态调整
固定框架 + 动态列表的证书/报告/合同
- 模板框架固定(标题、Logo、落款)
- 中间区域是动态列表,自动垂直堆叠
- 支持多列表(如细胞列表 + 检测项目列表),按声明顺序排列
自动换行、动态页面高度、多列表堆叠
- 按字符实际宽度精确换行,不截断汉字
- 页面高度根据内容自动伸缩,底部不留白
- 多个列表自动垂直排列,互不干扰 ** 空字段自动跳过** 字段值为 null 或空字符串时,不占页面空间,不留空白行
扩展用法:多页的PDF拼接 上述需求,如果人工绘制时页面过长导致分页,就可以考虑将其抽象成模版,并使用引擎。最终只会生成一页长PDF。间接做到多页的拼接,例如我就准备把我的一页多一点点的简历拼成一个长页。
6.2、不能做什么:
行内混排(如图文同行) 表格、多列布局
6.3、扩展点:
前置处理: 调用方可使用引擎方法提前计算高度,用该高度创建页面后传入引擎。 后置处理: 例如引擎返回绘制好的文件路径,调用方自己添加边框、水印、落款等。
七、最终效果与数据
1、初始的模版(有点丑,随便弄得一个)
2、两个细胞不换行情况
3、两个细胞换行的情况
八、总结与展望
项目代码已上传 GitHub:
未来可扩展方向:
- 列表内部支持图片。
- 列表内部嵌套列表,递归处理。
- 行内元素,一行多个可变元素并单独布局。