ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

iText Paragraph实战:从文本积木到PDF排版避坑全指南

iText Paragraph实战:从文本积木到PDF排版避坑全指南

做Java后端的人,十个人里总有七八个迟早遇到导出PDF这种需求。我第一次搞这个的时候,想法特别简单:把文本往页面上怼呗。后来被现实反复教育,PDF虽然顶着一个“文档”的名头,底层排版模型和HTML完全不是一回事,它没有一个神奇的浏览器帮你自动换行、自动排版。你必须把内容拆成iText能认的“积木块”,再一块块拼到页面上。在这些积木块里,出场率最高的就是Paragraph。

这篇文章只围绕一个点展开:iText里的Paragraph到底怎么用。默认你是有一定Java基础,正在用iText 5或者iText 7,两个版本我都会给代码,免得对着老教程抄的时候卡壳。正文会从最基础的构造讲起,到字体、对齐、缩进、行距这些高频参数,再到把图片、超链接、表格这些元素和Paragraph组合起来,最后落到我实际踩过的坑。看完之后,你能直接拿这套东西去生成合同、报表、说明文档这类常见PDF。

1. iText里的Paragraph到底是干什么的

1.1 项目里你真正需要的东西

处理PDF时最原始的方案是直接用PdfContentByte.showText()往页面上写字。这种写法能控制精确坐标,但问题是完全没有自动换行,没有段间距,也没有“这块内容放不下就挪到下一页”的机制。只要内容是动态的,或者字数不固定,你就得自己计算坐标、自己判断换行位置,非常痛苦,而且特别容易出问题。

Paragraph这个类把这些问题都收走了。它是一个带块级语义的文本容器,你往里塞字符串、字体、样式,它负责测量文本宽度、自动换行、决定渲染位置。日常做合同、发票、报告、电子凭证,绝大多数文本单元都能抽象成一个Paragraph。段落之间可以设置前后间距,文字能自动换行,超长内容还能顺延到下一页。

我见过不少产品需求,客户说“我要在这个位置显示一大段描述,字体要红色,左边空两个字符,如果太长就要换行”。每一句说的都是Paragraph能干的活。你用showText处理会想骂人,用Paragraph基本就是改几下参数的事。

1.2 从Document到页面的完整链路

搞清Paragraph的位置,得先理一遍iText生成PDF的渲染链路。大致是:

PdfWriter 写文件 -> Document 承载内容 -> Element 被解析 -> PdfContentByte 渲染 -> 输出到页面

Document相当于一个逻辑上的大白板,Paragraph是它上面最常用的Element之一。调用document.add()之后,iText会拿着这个Paragraph的字体、字号、缩进、间距、行距去做排版计算,如果当前页剩余空间不够,多余的内容会推到下一页。

这里还要提一下版本差异。iText 5里面,Paragraph继承Phrase,Phrase又继承ArrayList,本质上是一个带排版属性的文本块列表,你往里面加的每个Chunk都是一段独立的文本格式片断。iText 7把整个结构重写了,Paragraph改到了layout层,配合PdfFont和链式API使用,但块级语义完全保留。所以不管用哪个版本,你都可以把Paragraph理解成“一个会自动换行、自动定位的文本积木块”。

2. 先跑通最小可用代码:基础配置与必备写法

2.1 版本与包名差异

iText 5的老接口构造Paragraph很直接。一个能跑的最小样例长这样:

import com.itextpdf.text.Document; import com.itextpdf.text.Font; import com.itextpdf.text.Paragraph; import com.itextpdf.text.pdf.PdfWriter; Document document = new Document(); PdfWriter.getInstance(document, new FileOutputStream("output.pdf")); document.open(); Font font = new Font(Font.FontFamily.HELVETICA, 12, Font.NORMAL); Paragraph p = new Paragraph("hello iText", font); p.setAlignment(Element.ALIGN_CENTER); document.add(p); document.close();

iText 7里Paragraph挪到了layout包,写起来更偏向链式风格:

import com.itextpdf.kernel.pdf.PdfDocument; import com.itextpdf.kernel.pdf.PdfWriter; import com.itextpdf.layout.Document; import com.itextpdf.layout.element.Paragraph; import com.itextpdf.layout.property.TextAlignment; PdfWriter writer = new PdfWriter(new FileOutputStream("output.pdf")); PdfDocument pdf = new PdfDocument(writer); Document document = new Document(pdf); Paragraph p = new Paragraph("hello iText") .setFont(PdfFontFactory.createFont()) .setFontSize(12) .setTextAlignment(TextAlignment.CENTER); document.add(p); document.close();

包名、字体接口差别很大,但“构造Paragraph,设置属性,add进Document”的核心流程两边一致。新项目建议直接上iText 7,长期维护更稳;老项目维持在iText 5也完全能干活,我下面会尽量同时给两边的写法。

2.2 中文不乱码的字体准备

字体是中文环境下的第一道坎。直接用Font.FontFamily.HELVETICA处理中文,输出多半是乱码或者一堆空白小方块。原因很简单:这种标准Type1字体不包含中文字形,你让iText用它去渲染“你好”,它根本不认识。

iText 5里常见做法是使用亚洲字体包,或者直接加载系统字体文件:

BaseFont bf = BaseFont.createFont("STSongStd-Light", "UniGB-UCS2-H", BaseFont.NOT_EMBEDDED); Font chineseFont = new Font(bf, 12, Font.NORMAL); Paragraph p = new Paragraph("你好,iText", chineseFont);

如果服务器是Windows,也可以直接指向字体文件,比如:

BaseFont bf = BaseFont.createFont("C:/Windows/Fonts/simsun.ttc,0", BaseFont.IDENTITY_H, BaseFont.NOT_EMBEDDED);

其中“,0”表示取TTC字库里的第一个子字体。要看你本机装了什么字体,宋体、微软雅黑都能在Fonts目录下找到。

iText 7里对应的写法是:

PdfFont font = PdfFontFactory.createFont("STSongStd-Light", "UniGB-UCS2-H"); // 或者 PdfFont sysFont = PdfFontFactory.createFont("C:/Windows/Fonts/simhei.ttf", PdfEncodings.IDENTITY_H);

如果你想用STSongStd-Light这类内置字体,需要确保引入了itext-asian这个依赖。构建时如果报错“Font 'STSong-Light' with 'UniGB-UCS2-H' is not recognized”,十有八九就是这个jar没加进依赖。这是老版本最典型的翻车现场,我自己一开始也栽过。

提示:用系统字体路径时,要特别留意运行环境。代码在开发机Windows上跑得好好的,部署到Linux服务器就会字体找不到。稳妥做法是把字体文件放进项目resources目录,用相对路径或字节流加载,绕开操作系统字体目录的差异。

2.3 字号、颜色与字重的几个高频设置

Paragraph的排版属性大多可以通过setter单独设置。先说字号,iText 5里是setFontSize(),iText 7里同样有setFontSize()。颜色方面,iText 5用java.awt.Color的子类BaseColor,iText 7里更推荐用ColorConstants。

一个常见需求是正文12pt、黑字,标题16pt、蓝字。可以分别构造两个Paragraph:

// iText 5 Font titleFont = new Font(bf, 16, Font.BOLD, BaseColor.BLUE); Font bodyFont = new Font(bf, 12, Font.NORMAL, BaseColor.BLACK); Paragraph title = new Paragraph("月度报告", titleFont); Paragraph body = new Paragraph("以下是本月的数据汇总...", bodyFont);

注意一点:Paragraph本身没有单独控制粗体和斜体的开关,它靠的是传入的Font对象。如果你需要同一个段落内不同文字用不同字体,那Paragraph只能当容器,具体样式要交给内部的Chunk。这个后面第4章详细说。

3. 排版控制:间距、缩进、行距与分页

3.1 用段间距给版面留呼吸感

很多人用Paragraph时会忽略段落之间的空隙,结果一页上堆了十几个段落,全部挤在一起,PDF看起来像没排过版的txt直接导出。

Paragraph提供了setSpacingBefore()和setSpacingAfter()。Before是这个段落和上一个元素之间的距离,After是它和下一个元素之间的距离,单位是点。两者配合使用,文档的层次感立刻就有了。

我一般按这个习惯来设置:

  • 文档大标题:SpacingBefore 18,SpacingAfter 12
  • 章节标题:SpacingBefore 12,SpacingAfter 8
  • 正文段落:SpacingAfter 6到8

注意这套逻辑在嵌套表格里同样起作用。比如单元格里的Paragraph设置SpacingBefore,实际影响的是cell内上部边缘到文字的距离,利用这一点可以不用频繁改PdfPCell的padding。

3.2 首行缩进与左右缩进

中文正文通常需要首行缩进两个汉字,Paragraph直接有setFirstLineIndent()。缩进单位也是点,一般正文12pt字号,缩进两个汉字给24pt左右。有人习惯写25f,实际差一两个点肉眼看不太出来。

左右缩进分别用setIndentationLeft()和setIndentationRight()。这两个属性常用于引用块、注意事项这类特殊文本。比如你要在合同里放一段免责声明,左右各缩进20pt,视觉上自然就和其他条款区分开了。

用起来很简单,但要注意:左右缩进对整个段落生效,包括自动换行后的所有行。如果你想做类似网页里的“悬挂缩进”——首行顶格,其余行全部缩进——Paragraph原生不支持,得自己用Chunk去拼行,代码会复杂很多。我一般宁可拆成多个Paragraph,也不在那个坑里折腾。

3.3 行距:固定值还是倍数

Paragraph的行距在iText 5里用setLeading()控制。这个方法有两种重载:setLeading(float fixedLeading)是固定行距,比如指定20f,那所有行高度都是20点,适合对排版有精确要求的容器;setLeading(float fixed, float multiplied)则是两套结合,用字号和乘数共同计算行高。

你如果创建一个Paragraph后完全不设置行距,iText会用默认字体字号去计算。当段落里同时有12pt和16pt的文字时,行高会跟随字号变化,这时候用乘数方式更稳,避免大号字被压得贴在一起。

iText 7里对应的是setMultipliedLeading(value)和setFixedLeading(value)。我实际做中文文档一般直接setMultipliedLeading(1.5f),默认的1.0在部分字体下偏挤,1.5在宋体和雅黑下观感都不错。

注意:行距设置只影响Paragraph内部每行的高度,不影响段落之后的空白。段前段后的空隙仍然由SpacingBefore/After控制,两者别混在一起调。

3.4 分页控制与段落不拆分

默认情况下,一个Paragraph跨页时会被拆开,上半截留在上一页末尾,下半截从下一页继续。正文内容这么处理问题不大,但遇到标题、提示框这类内容,拆开就非常难看。

Paragraph有个setKeepTogether(boolean),意思是尽量让整个段落留在同一页。如果当前页剩余空间不足,它会把整个段落挪到下一页。这里有个边界案例必须说清楚:当段落本身高度超过一页时,iText没法把它塞进单页,会退化为可拆分,这是预期行为,不要指望超长文本也能保持完整。

除了keepTogether,iText 5里还有个setSplitLate()。默认情况下,Paragraph跨页时会优先把当前能放下的内容放到本页,剩下的排到下一页。如果把splitLate设为true,会尽量让段落整体留在下一页,避免出现只有一两行押在上页底部的情况。这个细节在排版要求高的项目里很实用。

4. 在段落里组合更多结构:Chunk与内联元素

4.1 用Chunk实现局部加粗变色

Paragraph整体只能有一个字体设置,但业务文案经常要求“开头几个字加粗,中间一段红色,末尾是普通颜色”。Paragraph解决不了这种需求,真正的刀是Chunk。

// iText 5 BaseFont bf = BaseFont.createFont("STSongStd-Light", "UniGB-UCS2-H", BaseFont.NOT_EMBEDDED); Font redBoldFont = new Font(bf, 12, Font.BOLD, BaseColor.RED); Font plainFont = new Font(bf, 12, Font.NORMAL, BaseColor.BLACK); Paragraph p = new Paragraph(); p.add(new Chunk("加粗红色", redBoldFont)); p.add(new Chunk("普通颜色", plainFont)); document.add(p);

Chunk是iText里精确到片段的最小文本单元。一个Paragraph里可以放N个Chunk,每个Chunk都能独立设置字体、字号、颜色、上标、下标、背景色。Paragraph负责换行和排布,Chunk负责片段显示。两者是组合关系。

iText 7里Chunk换成了Text,用法更链式:

PdfFont boldFont = PdfFontFactory.createFont("STSongStd-Light", "UniGB-UCS2-H"); Paragraph p = new Paragraph() .add(new Text("加粗红色").setFont(boldFont).setFontColor(ColorConstants.RED)) .add(new Text("普通颜色"));

新项目直接用Text就行,那是后来的标准接口。老项目接手的代码里看到Chunk也别慌,逻辑完全一样。

4.2 在段落里插入超链接和锚点

PDF文本可以带超链接,最常见的是给文字加上URL。在iText 5里可以这样:

Font linkFont = new Font(bf, 12, Font.UNDERLINE, BaseColor.BLUE); Chunk link = new Chunk("iText官网", linkFont); link.setAnchor("https://example.com"); p.add(link);

渲染出来的文字会变成可点击的超链接。注意链接文字的颜色经常会被文档默认配色影响,所以我在构造Font时一般显式写出下划线和蓝色,效果比较可控。

另外还有一种需求是目录跳转,就是点击某一页的“第3章”,页面自动跳到第29页。这种情况只加setAnchor还不够,需要给目标位置也设置命名的PDF目标,然后再用Chunk#setAnnotation指定跳转目标,两端配合才能实现。真要做得完整,建议直接查iText里的PdfAction和PdfDestination相关API,Paragraph层面只是挂动作的壳。

4.3 行内图片与图标

段落里也不全是文字,合同模板经常要把公司logo放在某一行文本旁边。Paragraph里可以直接add图片:

// iText 5 Image img = Image.getInstance("logo.png"); img.scaleToFit(20, 20); Paragraph p = new Paragraph(); p.add(img); p.add(new Chunk("公司名称", font));

这里有个容易踩的坑:Image对象放进Paragraph后,默认会占它本身的高度,特别容易把行距撑爆。做logo和文字同行时,我一般先scaleToFit,再配合Paragraph的行距设置微调。否则iText会按图片高度计算这一行的实际行高,整段文字间的空隙会变得非常怪异。

在iText 7里写法类似,用ImageDataFactory.create()构造图片资源:

Image logo = new Image(ImageDataFactory.create("logo.png")); logo.scaleToFit(20, 20); Paragraph p = new Paragraph().add(logo).add("公司名称");

行内图片还有一个用途是替代符号,比如用一张勾选图片代替手写的“√”,用红色叉代替“×”。注意这些图片尺寸不要超过正文行高,否则会影响整段排版。

5. 段落与其它组件的协同工作

5.1 把Paragraph放进单元格

真正的业务报表大部分是表格。iText 5里的表格叫PdfPTable,iText 7里叫Table。单元格要显示多行复杂文本,必须用Paragraph包一层,不能直接把字符串塞进去。

iText 5风格:

PdfPCell cell = new PdfPCell(); cell.addElement(new Paragraph("单元格里的内容", chineseFont));

iText 7风格:

Cell cell = new Cell(); cell.add(new Paragraph("单元格里的内容"));

为什么非要用Paragraph包一层?因为直接cell.setPhrase("字符串")虽然能显示,但串行样式、间距、对齐、缩进这些能力全都受限。而用Paragraph作为单元格内容时,段落本身的所有排版属性都生效,比如右对齐、上下间距、首行缩进等。

我生成产品明细账、订单列表时,基本每个单元格都创建一个Paragraph,再单独设置对齐方式。金额列右对齐,品名列左对齐,表格看起来比一条长字符串拼接整齐得多。

5.2 用Paragraph做多列排版

有些版面要求文字从左栏延伸到右栏,类似报纸分栏。Paragraph本身不直接支持分栏,但可以配合ColumnText实现。ColumnText可以设定一个矩形区域,然后把Paragraph放进这个区域里排版:

ColumnText ct = new ColumnText(writer.getDirectContent()); ct.setSimpleColumn(rectLeft); ct.addElement(p1); ct.go(); ct.setSimpleColumn(rectRight); ct.addElement(p2); ct.go();

这是一种按区域分列的写法:左栏放一段,右栏放一段。但如果你想实现“文本从左栏排满后自动流到右栏”,就需要自己控制高度了。ColumnText.go()执行后,可以通过getYLine()拿到当前排到的y坐标,然后判断是把剩余内容继续排到下一栏还是下一页。

这种连续流式分栏代码量不小,但逻辑不复杂,核心就是不断判断剩余空间、切换矩形区域、继续addElement。我自己做双栏简历模板时就是这么干的。

5.3 列表结构中同样藏着Paragraph

iText里使用List和ListItem可以生成有序或无序列表。ListItem本身继承自Paragraph,天然拥有Paragraph的所有能力。

List list = new List(List.UNORDERED); list.setIndentationLeft(30); list.add(new ListItem("项目一", font)); list.add(new ListItem("项目二", font)); document.add(list);

因为ListItem继承Paragraph,所以可以继续使用setSpacingBefore、setFirstLineIndent、setAlignment这些方法。列表不算高频需求,但如果你要生成一堆带圆点的条款,用List比手动拼“•”再换行方便得多。

6. 高频踩坑与排查手记

6.1 中文乱码的彻底解法

乱码问题看了一圈最终就两条:一是字体是否包含中文字形,二是编码方式是否和字体匹配。

iText 5里用系统TTC文件时建议用BaseFont.IDENTITY_H,这是按Unicode字形映射的方式,对绝大多数TTF/TTC字体通用。非要用UniGB-UCS2-H也不是不行,但那是给内置的一些亚洲字体准备的,和普通TTC搭配容易出问题。

更稳妥的方案是把字体文件放到项目里,用字节流加载。这样部署环境无论Windows还是Linux,都不会因为系统字体目录不一样而报错:

InputStream is = MyClass.class.getResourceAsStream("/fonts/simsun.ttf"); byte[] fontBytes = IOUtils.toByteArray(is); BaseFont bf = BaseFont.createFont("simsun.ttf", BaseFont.IDENTITY_H, BaseFont.EMBEDDED, true, fontBytes, null);

这种写法绕开了操作系统字体路径,是项目化交付时比较稳的解法。唯一代价是字体文件会变大一点,但PDF里字体嵌入后迁移不易出问题。

6.2 行距和间距计算偏差

如果你用固定行距setLeading(20f),同时段落里又混入图片或大号字体,行高很容易超出预期,导致下面文字被挤到下一页。排查的时候先关掉SpacingBefore,再逐步加回,看是哪层参数引起的溢出。

文本宽度测量也是常遇到的问题。比如固定宽度单元格里,需要判断字符串是否超过宽度,从而决定是否加省略号。iText 5里可以直接用BaseFont测量:

BaseFont bf = ...; float textWidth = bf.getWidthPoint("这是一段测试", 12);

iText 7里对应的是font.getWidth()。量宽度做自动截断很好用,尤其是生成动态报表时,能提前知道一行能不能放下。

6.3 对象复用和空段落的坑

有人图省事,循环里反复document.add()同一个Paragraph对象。iText内部会把之前的渲染状态留在对象上,第二次添加时行为不可预期,甚至会直接抛异常。我的建议很简单:每次需要内容就new一个Paragraph,别复用。

另一个坑是空Paragraph。如果你创建一个new Paragraph(""),它依然会被当作有效元素,占用一行的空间和SpacingBefore/After,导致后续元素坐标偏移。解决方法是add之前判断文本是否为空:

if (text == null || text.trim().isEmpty()) { return; } document.add(new Paragraph(text, font));

这段判断看着简单,但能避免掉很多莫名其妙的多余空白。

6.4 定位排版问题的debug技巧

PDF不像控制台程序,报错时不一定指明是哪个Chunk吃坏了。我的经验是先做“最小化定位”:拿掉样式,只保留文本,确认文字能出来,再一项项把字号、颜色、行距、间距加回去,看哪一步把布局搞坏。

怀疑坐标偏移时,iText 7里可以给Paragraph临时加个背景色,直接看到文本实际占用的矩形区域。iText 5里Paragraph没这么方便,可以临时把Paragraph塞进一个带边框的PdfPCell里,通过边框看它实际占多大空间。看完再删掉,这个手段对定位“为什么这一段把我后面的内容挤走了”非常有效。

PDF调试还有个笨办法但极其好用:生成之后用预览工具放大看页面的空白区域,对比自己设置的Spacing值,基本能猜出哪个边距算错了。PDF不像Web有审查元素,大部分时候得靠视觉检查加合理猜测。

7. 性能与内存:Paragraph虽小,批量生成时不能大意

批量导出场景下,比如一次生成几百甚至几千个PDF,Paragraph的构建方式会影响性能。很多人喜欢写一个帮助方法,每次都用相同字体创建Font,再创建Paragraph。这个做法没问题,但要注意字体对象别每次重复加载同一个TTF文件。

iText 5里BaseFont有缓存机制,重复createFont("simsun.ttf", ...)并不会每次都重读文件,只要cached参数为true即可。但要小心,如果你用getResourceAsStream动态读取字体字节流再createFont,某些情况下会绕过缓存,等于每生成一段就重读一次字体文件,性能下降非常明显。

处理大批量任务时我习惯把用到的Font对象提到循环外面,当成员变量复用。Paragraph本身就别复用了,但Font、BaseColor这些不可变对象可以放心复用,省掉很多不必要对象创建。

// 循环外初始化 Font titleFont = new Font(bf, 16, Font.BOLD); for (Map<String, Object> row : rows) { Paragraph p = new Paragraph(row.get("title").toString(), titleFont); document.add(p); }

iText 7也是类似的道理,PdfFont可以先创建并保存,Paragraph里的add操作每次构造新的就行。

8. 从Paragraph开始的后续扩展

用Paragraph能解决大多数文本型PDF需求,但有些场景还需要往外扩一把。

第一个扩展方向是模板。固定结构的合同、证书,通常不会每次都写一遍代码,而是做一个通用渲染方法,把Paragraph的样式参数抽成配置,比如字体、字号、颜色、间距都放在一个Map或者模板文件里。这样业务人员改样式不用动代码。

第二个方向是自定义元素。如果Paragraph解决不了特殊排版,比如要做环形文本、竖排文字,就需要深入到PdfContentByte层去画。Paragraph在这种时候更像是设计起点,你先用Paragraph把常规内容排好,再围绕它补充手绘部分。

第三个方向是配合PdfWriter的事件机制,添加页眉页脚。页面事件回调里拿不到Paragraph的高级排版能力,但你可以根据当前页内容动态创建一个Paragraph,放到页眉区域。这种做法在正式报告里很常见。

我个人在实际项目中的体会是,Paragraph最舒服的使用方式是“一个语义单元对应一个Paragraph”。不要试图把一整页内容塞进一个Paragraph,也不要一个段落拆成十几个Paragraph去手动拼,前者失去控制力,后者排版容易乱。按照内容逻辑自然分段,再用间距和缩进控制层次,这样源码可读性高,后期改样式也方便。

最后再分享一个小技巧:每次写完Paragraph相关代码,顺手用一个固定文本的样例跑一遍,输出来肉眼检查对齐方式。PDF排版高度依赖视觉确认,光靠参数推算容易漏掉细节。培养这个习惯之后,你在PDF导出项目里踩坑的次数会明显少很多。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进