
做Java后端这些年凡是要交付一份能打印能存档的正式文件基本都绕不开PDF。合同、装箱单、采购审批表、工资条几乎每个项目都会藏着一个“把数据库里的中文数据排版成PDF”的隐形需求。真上手做的时候你会发现一个不争的事实PDFBox和iText往PDF里写英文一切岁月静好换成中文再谈对齐马上进入地狱难度。我第一次认真处理中文PDF排版是给一个供应链系统做装箱清单导出。英文模板跑得顺顺利利签到栏、数量栏、金额栏全都在一行上站得笔直。把字段换成中文之后问题一下子炸出来量词和中文字符挤成一团右侧金额怎么也对不齐要么当前行直接溢出要么一行字忽上忽下客户验收时直接在截图里圈了一句“格式错乱必须重排”。从那以后我把中文PDF对齐这件事从原理到实现彻底捋了一遍。这篇文章不聊花架子把我踩过的坑、用过的库、最终沉淀下来的可复现代码一次性讲清楚。适合正在用Java生成中文PDF、被对齐问题反复折磨的后端开发者也适合准备做报表系统、电子票据导出的同学提前避雷。1. 中文PDF对齐问题到底出在哪1.1 三个主流库怎么选PDFBox、iText、Flying Saucer做Java PDF方案选型时绝大多数人是在PDFBox和iText之间犹豫还有一部分会把Flying Saucer这类HTML转PDF的方案拉进来对比。我三个都用过先说结论没有绝对的银弹只有适不适合当前场景。PDFBox的优势是轻量、纯Java依赖少、所有API都暴露给你坐标计算、字距、行距全部可控。缺点是很多东西要自己造轮子比如自动换行、两端对齐、表格边框都需要手写逻辑。它的定位更接近一个底层渲染引擎适合对排版位置有极高精度要求的场景比如票据套打、热敏标签、快递面单。iText则是开箱即用的业务报表方案。Paragraph、ColumnText、PdfPTable这些高层API把对齐、换行、分页都封装好了你只需要把数据和样式丢进去。尤其iText 5时代的BaseFont加亚洲字体包到现在还有大量老项目在用。缺点也不是没有iText 7的API变化很大网上老博客里的代码经常抄了编译不过开源的AGPL协议在商用项目中需要仔细评估授权虽然用的人很多但合规问题别装作看不见。Flying Saucer加上wkhtmltopdf这类方案本质是把HTMLCSS渲染成PDF。如果团队本身就是做Web开发的前端写好页面再转PDF成本最低复杂表格也难不倒它。但要注意两个坑一是构建环境往往需要依赖本地渲染进程Docker化时会踩系统库缺失的坑二是CSS的打印适配和浏览器看到的页面效果差距很大必须单独做打印样式表否则“网页显示好好的导出来全乱了”。就中文对齐这个需求而言我的建议是项目要大、要稳、要快选iText项目要精确到像素、要手动控制每个字符位置选PDFBox项目本来就是网页报表、前端资源丰富就上Flying Saucer。下面两章我会重点讲PDFBox和iText的实操写法因为它们代表了两条最主流的路线。1.2 对齐的底层逻辑字体度量与坐标系统要说清楚对齐必须先承认一个事实PDF里的文本没有“自动靠齐”这回事所有对齐最终都落在坐标计算上。一个字符画在页面的哪个位置完全由你传给渲染器的x、y坐标决定。所谓左对齐不过是把所有行的x设为同一个值右对齐就是每行x等于右边界减去该行宽度居中则等于左右边界的中心线减去半个行宽。这里绕不开的两个度量单位是pt磅和“千分单位”。PDF页面尺寸默认单位是pt一张A4纸的宽是595pt、高是842pt。而字体文件的度量通常是在1000个单位/em的坐标系下定义的getStringWidth算出来的就是这种千分单位。要换算成pt公式是float textWidthPts font.getStringWidth(text) * fontSize / 1000f;很多新手漏掉这个换算直接把getStringWidth的结果当成pt用出来的宽度比预期大了几百倍对齐自然全乱。所以第一步就是把“字体度量”和“实际页面坐标”之间的关系吃透。字体行高也一样。每个字体都有自己的Ascent上升部和Descent下降部中文字体因为汉字结构复杂Ascender/Descender往往和英文字体差异明显。后面讲基线的时候我会再展开这里只需要记住不要用眼睛估不要用空格凑一切宽度计算都走字体度量API这是中文对齐问题的根基。2. PDFBox手写对齐从坐标到像素2.1 基础框架加载中文字体并输出文本先写一个最基础的PDFBox程序。注意PDFBox 2.x和3.x的API在包名上有区别本文代码基于2.x但核心思路在3.x同样适用。import org.apache.pdfbox.pdmodel.PDDocument; import org.apache.pdfbox.pdmodel.PDPage; import org.apache.pdfbox.pdmodel.PDPageContentStream; import org.apache.pdfbox.pdmodel.common.PDRectangle; import org.apache.pdfbox.pdmodel.font.PDFont; import org.apache.pdfbox.pdmodel.font.PDType0Font; import java.io.File; import java.io.IOException; public class PdfAlignDemo { public static void main(String[] args) throws IOException { try (PDDocument document new PDDocument()) { PDPage page new PDPage(PDRectangle.A4); document.addPage(page); // 关键词加载中文字体路径按实际环境调整 PDFont font PDType0Font.load(document, new File(simhei.ttf)); try (PDPageContentStream cs new PDPageContentStream(document, page)) { String text 中文对齐实测; float fontSize 12; float margin 40; // A4宽度595pt内容区就是595-40*2 float contentWidth page.getMediaBox().getWidth() - 2 * margin; float y page.getMediaBox().getHeight() - margin; float textWidth font.getStringWidth(text) * fontSize / 1000f; // 左对齐起点固定为margin cs.beginText(); cs.setFont(font, fontSize); cs.newLineAtOffset(margin, y); cs.showText(text); cs.endText(); // 右对齐x 页面宽 - margin - 文本宽 float rightX page.getMediaBox().getWidth() - margin - textWidth; cs.beginText(); cs.setFont(font, fontSize); cs.newLineAtOffset(rightX, y - 20); cs.showText(text); cs.endText(); // 居中对齐x (页面宽 - 文本宽) / 2 float centerX (page.getMediaBox().getWidth() - textWidth) / 2; cs.beginText(); cs.setFont(font, fontSize); cs.newLineAtOffset(centerX, y - 40); cs.showText(text); cs.endText(); } document.save(align-demo.pdf); } } }这段代码把三种基础对齐都覆盖了关键就是那三行坐标计算。如果你用的字体是那种内置的英文字体比如Helvetica它根本不含中文字形写进去就是乱码或者方框这一点我后面在问题排查部分还会再强调。2.2 左中右对齐核心公式与抽取方法在实际项目里很少有人只画一行字。更常见的需求是把一组文本按不同对齐规则摆到页面指定区域里。我习惯把坐标计算抽成一个独立方法方便复用enum AlignType { LEFT, RIGHT, CENTER } private float computedX(String text, PDFont font, float fontSize, float contentLeft, float contentWidth, AlignType align) throws IOException { float textWidth font.getStringWidth(text) * fontSize / 1000f; switch (align) { case LEFT: return contentLeft; case RIGHT: return contentLeft contentWidth - textWidth; case CENTER: return contentLeft (contentWidth - textWidth) / 2; default: return contentLeft; } }这里有一个细节容易被忽略居中对齐到底是相对页面居中还是相对某个内容区域居中。如果是打印单据通常是对内容区域居中也就是先算出左边距和总内容宽度再在这个区域内居中。上面方法里的contentLeft和contentWidth就是为这种场景准备的。以A4页面为例页面宽度595margin设为40则内容宽度为515。硬编码的595虽然在当前场景马上能用但一旦换成A5、票据纸全得重来。所以我建议把页面尺寸和边距都从配置读取这是保证模板可维护性的第一道防线。还有一点PDFBox的showText对超长文本不会自动换行所以画多行内容时必须自己拆行行距也要手动控制。最朴素的办法是先算出每行文本宽度超宽就换一行再把y坐标按固定行距递减private ListString splitText(String text, PDFont font, float fontSize, float maxWidth) throws IOException { ListString lines new ArrayList(); StringBuilder line new StringBuilder(); for (char c : text.toCharArray()) { float w font.getStringWidth(String.valueOf(c)) * fontSize / 1000f; if (line.length() 0 lineWidth w maxWidth) { lines.add(line.toString()); line.setLength(0); lineWidth 0; } line.append(c); lineWidth w; } if (line.length() 0) { lines.add(line.toString()); } return lines; }中文断行按字符切通常没问题因为每个汉字都是独立方块。英文不按空格断行会直接把单词腰斩所以英文场景要再改进按空格分词拆行。这算是中文场景下的小福利。2.3 两端对齐逐字算坐标的土办法但真管用PDFBox没有内置的两端对齐借贷场景、单据描述栏里又特别喜欢这种效果。最可靠的做法是把文本拆成单个字符逐个绘制字与字之间的间距按剩余空间均匀分配。private void drawJustifiedText(PDPageContentStream cs, PDFont font, float fontSize, String text, float x, float y, float targetWidth) throws IOException { char[] chars text.toCharArray(); // 单个字符绝对宽度 float unit fontSize / 1000f; float[] charWidths new float[chars.length]; float totalWidth 0f; for (int i 0; i chars.length; i) { charWidths[i] font.getStringWidth(String.valueOf(chars[i])) * unit; totalWidth charWidths[i]; } // 如果文字已经超出目标宽度或者只有一个字退化为普通绘制 if (chars.length 1 || totalWidth targetWidth) { cs.beginText(); cs.setFont(font, fontSize); cs.newLineAtOffset(x, y); cs.showText(text); cs.endText(); return; } // 把剩余空间分摊到字符间的每个空隙 float extraSpace (targetWidth - totalWidth) / (chars.length - 1); float cursorX x; for (char c : chars) { cs.beginText(); cs.setFont(font, fontSize); cs.newLineAtOffset(cursorX, y); cs.showText(String.valueOf(c)); cs.endText(); cursorX charWidths[i] extraSpace; } }这个方法看起来很笨但胜在精确。它有两个明显的优点一是完全绕开了“空格宽度”这个不变量因为直接在每个字符间隙加等量偏移不会受到空格宽度波动的影响二是中文标点、全角字符都能按真实度量计算不会出现某一行最后多出一截空白的情况。做英文两端对齐时不要用这个逐字符方案否则单词内部的字母间距会被拉扯得很怪异。英文应该按空格把文本切成单词数组在单词之间均匀分配间隙。中文用逐字符英文用逐词这个区分很重要千万别图省事一套逻辑通吃。3. iText省心之路Paragraph的三行代码3.1 iText 5经典写法BaseFont与ALIGN_JUSTIFIEDiText 5的写法在今天是老古董但存量项目真的多而且这套API的逻辑简单直白。加载中文字体有两种路子一种是依赖iTextAsian包里的内置亚洲字体BaseFont baseFont BaseFont.createFont(STSong-Light, UniGB-UCS2-H, BaseFont.NOT_EMBEDDED); Font chineseFont new Font(baseFont, 12, Font.NORMAL, BaseColor.BLACK);这里的“STSong-Light”是iTextAsian提供的内置字体名称配合“UniGB-UCS2-H”编码不需要额外的字体文件部署很省事。缺点是没有真正嵌入字体到PDF中如果对方电脑没有这套字形映射渲染出来的样式可能有细微差异。如果要嵌入具体字体文件用另一个构造方式BaseFont baseFont BaseFont.createFont(/path/to/simhei.ttf, BaseFont.IDENTITY_H, BaseFont.EMBEDDED);IDENTITY_H代表使用Identity-H编码它是处理中日韩统一表意文字的标准编码方式。我实际用下来嵌入字体生成的PDF更稳妥即使到了打印店、别的操作系统也不会出现字体缺失问题代价是PDF文件体积会大几十KB到几MB不等。对存储空间敏感的业务比如多份文件批量发送邮件要考虑这个代价。有了字体对齐就非常简单了Paragraph left new Paragraph(这是左对齐文本, chineseFont); left.setAlignment(Element.ALIGN_LEFT); Paragraph center new Paragraph(这是居中文本, chineseFont); center.setAlignment(Element.ALIGN_CENTER); Paragraph right new Paragraph(这是右对齐文本, chineseFont); right.setAlignment(Element.ALIGN_RIGHT); Paragraph justify new Paragraph(这是需要两端对齐的中文段落会在字符间隙自动分布空白。, chineseFont); justify.setAlignment(Element.ALIGN_JUSTIFIED);iText 5的ALIGN_JUSTIFIED对中文的处理以现在的眼光看也算合格它会把多余宽度尽量均匀分配到处理两端对齐的“可伸展点”上。对纯中文段落它的分配结果和Word的分散对齐略有差异但不细看根本发现不了所以绝大多数业务场景直接用它就行。用iText 5做表格时单元格对齐也要注意水平与垂直两个维度分开设置PdfPCell cell new PdfPCell(new Phrase(100.00, chineseFont)); cell.setHorizontalAlignment(Element.ALIGN_RIGHT); cell.setVerticalAlignment(Element.ALIGN_MIDDLE); table.addCell(cell);报表里数字右对齐、文字左对齐几乎是行业惯例横向对齐决定一行内文本左右位置纵向对齐决定多行内容在单元格里的上下位置。很多人只设了水平对齐垂直不设结果单元格里孤零零一行字贴在上边线怎么看都别扭。3.2 iText 7新API最容易被老博客坑到的地方iText 7的API设计和5代完全不同PdfFontFactory替代了BaseFontTextAlignment替代了整数常量。很多老博客的代码格式在这里直接编译不过。PdfFont font PdfFontFactory.createFont(STSong-Light, UniGB-UCS2-H, false); Paragraph left new Paragraph(左对齐).setFont(font).setFontSize(12) .setTextAlignment(TextAlignment.LEFT); Paragraph center new Paragraph(居中对齐).setFont(font).setFontSize(12) .setTextAlignment(TextAlignment.CENTER); Paragraph right new Paragraph(右对齐).setFont(font).setFontSize(12) .setTextAlignment(TextAlignment.RIGHT); Paragraph justify new Paragraph(这是两端对齐的中文段落内容示例可以在iText 7中正常工作。) .setFont(font).setFontSize(12) .setTextAlignment(TextAlignment.JUSTIFIED);注意几个差异点其一createFont的第三个参数是embedded布尔类型如果传true就是嵌入字体其二Paragraph不再支持setLeading这种传统写法统一通过setFixedLeading或setMultipliedLeading控制行距其三add方法从document.add(paragraph)改为document.add(paragraph)这个没变但Cell里的换行方式和旧版不同。我实际迁移项目时踩过一个非常隐蔽的坑iText 7中直接用createFont(STSong-Light, UniGB-UCS2-H, false)时如果没有在classpath里放对应版本的itext-pdfasia运行时会抛字体注册异常。很多老代码转到新版本时把jar包也换成了新版的iText7结果发现亚洲字体包其实是另一个构件不是核心包自带的。排查时看异常栈能看到PdfFontFactory注册失败的提示去Maven仓库找对应的亚洲字体artifactId补上即可。表格单元格对齐在iText 7里也变了Cell cell new Cell().add(new Paragraph(100.00).setTextAlignment(TextAlignment.RIGHT)); cell.setVerticalAlignment(VerticalAlignment.MIDDLE);这里的setVerticalAlignment是Cell自身的方法参数是VerticalAlignment枚举。如果从旧项目迁移需要把大量PdfPCell替换成Cell工作量不是小数目但它换来的是更现代、更统一的对象模型长期维护反而省心。3.3 表格里的对齐别漏了垂直方向无论iText 5还是7表格是报表对齐的主战场。表头、正文、金额三列的对齐规则往往各不相同。我建议在设计阶段就把对齐规则固化成一个枚举或配置而不是在每个循环里硬编码文本列左对齐支持换行行距固定保持舒适阅读金额列右对齐小数点位对齐正负数颜色做区分日期列居中格式固定为yyyy-MM-dd表头居中加粗垂直居中一个容易出问题的地方是单元格内文本过长时如何换行。iText的PdfPCell默认会自动换行但换行后单元格高度会被撑开影响整页表格布局。处理长文本时我通常会单独用一个Paragraph设置固定行距再用列宽控制每行字符数上限这样至少能预估每行的高度方便计算表格总高度。还有个经验表格中单元格的垂直对齐在打印场景中非常重要。比如“备注”这一列内容可能有一到两行而右侧“金额”只有一行如果不设置垂直居中金额会贴到单元格顶部视觉上非常割裂。设置VerticalAlignment.MIDDLE后整个表格看起来才像一个严丝合缝的整体。4. 中英文混排与字体基线对齐的“隐形暗礁”4.1 全角、半角与字符宽度的微妙关系中文对齐绕不开“全角”和“半角”这两个概念。全角字符占一个汉字宽度半角字符占半个。在中文文本里出现英文字母、阿拉伯数字、半角标点时宽度计算就不能再想当然。最典型的场景是订单号或商品编码比如“BOX123中号”。如果你把前面英文字母当全角字符来预估宽度或者反过来把中文当英文宽度来算都会得到错误的对齐结果。解决思路只有一条不要人工估算宽度永远用字体字体度量API计算。在PDFBox里是getStringWidth在iText里是getWidthPointfloat textWidth font.getWidthPoint(BOX123中号, 12);这行代码的意思是在这套字体、12pt字号下这个混合字符串的实际宽度是多少pt。无论是全角还是半角字体度量程序都会按照字形本身计算不依赖你的人工判断。这个细节虽然小但报价单、清单、回执这类文件里商品编码混合字符几乎处处存在宽度算错一次整行对齐就崩一次。还有一类字符是中文标点比如逗号、句号、引号。它们在字体度量中往往并不是严格的等宽汉字宽度有的字体里全角标点占的宽度会略微超出或缩小。做两端对齐时逐字方案天然能应对这种偏差因为每个字符的宽度都是独立实测的。如果用空格填充来假装对齐遇到中文标点就会露出破绽。4.2 基线不对齐的根因与修正方法所谓基线简单理解就是一行文字底部那条看不见的参考线。西文字母、数字都稳稳坐在基线上中文汉字的视觉中心略微高于基线而拼音、下标、公式又各有自己的上下偏移规则。PDF渲染文本时默认把基线对齐到指定的y坐标所以当一行里同时有中文、英文和数字时你可能看到英文数字比中文略高或略低这就是基线不齐。处理方式有两种。一种是对整个文本块做整体偏移比如在用PDFBox绘制时把y坐标加上字体Descent的绝对值float ascent font.getFontDescriptor().getAscent() / 1000f * fontSize; float descent font.getFontDescriptor().getDescent() / 1000f * fontSize; float baselineY y descent;这个做法的意图是让不同字体在同一个视觉行内尽量“坐”在同一水平线附近。换成iText可以用Chunk的setTextRise方法做微调正数向上、负数向下Chunk ch new Chunk(H₂O 中文混排, chineseFont); ch.setTextRise(2f);这个2f是相对于基线的竖直偏移量单位是pt。上下标、公式符号都能通过它来修正视觉错位。比如H₂O下标需要下沉6ptCO₂同理。用setTextRise比手动换行再写字更好因为它不破坏四个字符原本的水平连续性。网上很多人遇到“公式与文字不对齐”的问题多半就是公式默认采用了更高的行高和普通中文段落混排时行顶和行底基线没有对齐。解决路径就是刚才说的一个是单独把公式区域的行距与正文字体行距强制一致另一个是给公式所在的Paragraph或Cell设置固定行距fixedLeading别让它按字体默认行高撑开。实测下来固定行距比乘以系数更可控因为中文字体默认行高普遍偏高用多倍行距会导致页面上下留白特别大。5. 常见问题排查与避坑技巧5.1 高频翻车现场现象、原因、解决方案先把最容易踩的坑列成一张速查表这些全是我自己或朋友在项目中真实遇到过的现象可能原因解决方案中文全部变成方框或乱码没加载中文字体或字体文件不含对应字符用PDType0Font.load加载ttf/ttc或iText注册中文字体中文文本不自动换行一行横冲直撞PDFBox没有内置换行自己按字符或词拆行左右能对齐但一行字符忽高忽低不同字体基线不一致统一加载同一字体或设置固定行距、调整基线iText导出报字体创建异常缺少亚洲字体包单独引入亚洲字体构件或注册外部字体文件数字与中文混排后对不齐全角半角宽度混用人工估算出错统一用字体宽度API计算表格单元格文本垂直不居中只设置了水平对齐同时设置verticalAlignment网页编写时排版正常PDF打印后布局错乱打印CSS与屏幕CSS不一致单独做media print样式并处理页边距生成的PDF文件过大嵌入了多份字体或多次加载同一字体复用同一个PdfFont/PDFont实例避免重复注册最后一条我特别想多说两句。一些粗心的写法里循环里每次new一个字体对象几百行文本下去PDF体积直接膨胀到几十MB。正确做法是全局复用字体实例无论是PDFBox还是iText都是这样。检查PDF体积异常时第一反应就该查字体是否被重复嵌入了。5.2 我验证过的几条稳定原则把踩过的坑沉淀一下我总结出几条长期有效的原则。第一字体一定是显式加载别依赖运行环境的默认字体。服务器上装了什么字体、装没装你完全不可控。最稳的姿势是把simhei.ttf、simsun.ttc之类的字体文件随项目打包进resources部署时从classpath读取。这样任何环境跑出来结果都一样不会出现本地好好的、测试服务器变方框的荒诞局面。第二凡是涉及对齐坐标的计算全部封装成统一方法禁止在业务代码里散落硬编码。一个单据模板里可能有几十个字段今天这里改个边距、明天那里加个字段如果坐标都是散落的魔数维护成本指数级上升。我通常会把字段模板定义成配置结构包含字段名、x、y、字体大小、对齐方式生成PDF时统一解析执行。第三除了跑通代码一定要做“视觉验证”步骤。代码能运行和生成结果正确是两码事。最简单的验证方式是写一个单元测试生成PDF后用PDFBox的PDFRenderer把页面渲染成PNG图片再对关键区域做像素采样比对。不用做到 1:1 完美比对但至少可以在改代码后自动发现“某一行文字往左偏移了10pt”这种回归问题。我自己就用这种方法拦下过至少五次尴尬的排版回归。第四多行区域的排版先算高度再画。如果内容区是固定高度但你画了超出高度的内容PDFBox不会给你任何报错它会静静把文字画到不该出现的位置甚至叠到下一段的文字上。所以凡是动态多行内容先用行数和行距算出总高度再判断是否分页、是否需要收缩字号逻辑上要把“预计算”和“绘制”分成两步来做。5.3 从网页打印到PDF不同入口的差异要心中有数现在很多系统的PDF导出并不是直接调用库而是先把页面内容渲染成网页再通过浏览器打印或者服务端转PDF。这种方案的对齐逻辑依赖CSS的text-align和white-space本身实现容易但也带来了新的不一致问题。最大差异在于浏览器里的px和打印PDF中的pt。浏览器在屏幕显示时96dpi下1px约为0.75pt而打印机渲染时px和pt的换算又会受缩放比例影响。所以网页打印前必须在样式表里显式设置页面尺寸和边距page { size: A4; margin: 20mm; } body { font-family: Microsoft YaHei, SimSun, sans-serif; }这个page规则只对打印有效如果不写不同打印机的默认边距会让内容位置发生漂移。另外表格里的长文本建议设置table-layout: fixed和word-break: break-all避免连续英文或大段汉字撑破单元格。图片中的中文也别忘了设置合适的宽高比否则图片里的文字本身是点阵怎么缩放都糊。这类方案的一条捷径是页面打印样式单独写一份不要和屏幕样式混用。你会发现同样的HTML在浏览器和PDF中渲染出的行高、换行点都可能不同。单独做一套media print样式从一开始就按PDF标准调整能省掉无数“调完浏览器又坏了PDF”的来回折腾。6. 从项目实战里带出来的几个经验最后分享几个藏在具体项目里的决策思路它们不像代码那么直观但直接影响方案是否落地顺利。第一个经验是不要让一个组件既管内容又管排版。早期我写过一个PDF导出工具类里面既装载数据又画表格又算坐标结果一个单据格式微调就要改整份代码。后来我把“数据准备”和“渲染绘制”彻底拆开数据层只负责组装模型渲染层消费模型输出PDF。这样业务字段变动时数据层改动即可排版层基本不动排版需求变动时只要看渲染层数据不会受牵连。第二个经验是预留字段缺失时要有兜底策略。合同单上经常有备注字段有时数据库里是空的有时长到5行。空字段如果硬绘制会留下一块空洞长字段如果溢出又会顶开布局。我的做法是设置一个最大行数超出部分截断并追加省略号空字段则跳过绘制但保留位置占位。这种细节做得好客户基本不会在验收单上挑排版毛病。第三个经验是关于审阅文稿时的自查路径。每次交付PDF生成功能前我都会拿一份完全真实的数据跑一遍然后对照三个层面检查第一层是文字是否重叠、是否溢出边界第二层是字段之间间距是否一致比例是否协调第三层才是具体到某一行的左右对齐是否到位。很多新手只顾着第三层结果文字没重叠但上下行距忽大忽小这种问题用户一眼就能看出来却往往最后才被发现。这些原则听着朴素但坚持下来中文PDF对齐真的没有想象中那么玄。尖端工具再少底色还是字体度量、基线、页高页宽这些扎实的基础。你做过的每一张单据最后都会被打印出来摆在桌上那是代码和现实世界之间最诚实的一次握手。