基于PDFBox的轻量级可扩展流式布局组件

16 阅读14分钟

一、引言:为什么需要这样一个组件?

业务背景

之前工作中,我需要为储存细胞的用户自动生成储存证书。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:

github.com/jmingfu/pdf…

未来可扩展方向:

  • 列表内部支持图片。
  • 列表内部嵌套列表,递归处理。
  • 行内元素,一行多个可变元素并单独布局。