ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

Java生成电子签章实战:从Java2D绘制到PDFBox合成完整指南

Java生成电子签章实战:从Java2D绘制到PDFBox合成完整指南 简介基于Spring Boot框架的电子合同电子签章生成项目面向需要为PDF合同实现数字签名与可视化签章的Java开发者适合中高级工程师及企业合同系统建设人员参考。项目以Spring Boot作为核心运行环境采用微服务方式组织签章功能模块利用Java密码体系及增强加密库完成基于非对称算法的数字签名再借助PDF处理组件读取并修改文档将电子章图形和签名数据结构写入合同指定位置保障文件不可篡改且来源可验证。目前已有1249人浏览学习。压缩包约七十二KB内容集中精炼便于快速获取核心实现方案。根据项目说明代码结构涉及依赖配置文件、Java业务逻辑源码以及证书、密钥、电子章图片等资源涵盖接口调用、文件读写、PDF解析、签名生成与校验等完整技术环节。阅读后可帮助梳理电子合同场景中的安全与合规要点并提升在Spring Boot中集成加密及PDF处理能力。1. 电子合同里的Java生成电子签章它不是“贴图”而是三件事接到一个合同补签需求时最容易被低估的就是这个看起来人畜无害的“电子签章”。不少团队的第一次尝试是把公司Logo或者一张红章图片用坐标怼到PDF右下角然后联调时说“章不清晰”“章盖住字”“章是歪的”。用Java生成电子签章解决的不是“把图片贴上去”而是三件事用代码画出一枚符合视觉规范的章、把章按精确位置合成进电子合同、以及让这枚章在导出、缩放、跨平台路径下仍然稳定可用。这套技能对做OA、合同管理、订单回执、账单盖章的Java开发者都有用我后面写的全部内容都是围绕这个最小闭环展开的。2. 先用Java2D画出一枚能看的圆形公章图形、字体与坐标模型2.1 为什么自己画而不是用图片素材细线、高清屏与可定制化直接用PS做好一枚红章图片再让Java读进去看起来是最快路径但实际落地会有三个问题。第一是分辨率UI设计稿里的章通常是72dpi或者96dpi在Retina屏幕和PDF打印缩放场景下边缘会出现明显的锯齿章越大越明显。第二是定制化不同子公司、不同合同类型需要不同章面文字你不可能为每个组合都准备一张素材图。第三是透明背景问题从PS导出的PNG如果带了白色底合成到PDF后会像一个“白色创可贴”直接盖住合同正文。所以我自己的做法永远是“运行时绘制”用Java2D的Graphics2D在内存里把章画出来导出为ARGB透明背景的PNG再交给PDF合成层。这样既能用参数控制颜色、文字、尺寸也能在高分辨率下渲染出更平滑的笔画。2.2 核心绘制方法外圆、五角星与环绕文字圆形红章是电子合同里最常用的形态绘制模型其实很固定一个红色粗线外圆、一个中心五角星、沿着圆周排布的公司名称以及中轴线上的“合同专用章/公章”字样。下面这段代码用Java2D实现了完整绘制可以在Java 8以上的任何JDK环境直接运行。import javax.imageio.ImageIO; import java.awt.*; import java.awt.geom.Path2D; import java.awt.image.BufferedImage; import java.io.File; public class SealGenerator { public static void main(String[] args) throws Exception { int size 600; // 输出画布建议600起 BufferedImage image new BufferedImage(size, size, BufferedImage.TYPE_INT_ARGB); Graphics2D g image.createGraphics(); // 抗锯齿是印章边缘不发毛的关键 g.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); g.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_ON); g.setColor(new Color(203, 35, 39, 240)); // 标准印泥红透明度240 // 1. 外圆印章的外轮廓 g.setStroke(new BasicStroke(size / 36f)); int pad (int) (size * 0.06); g.drawOval(pad, pad, size - pad * 2, size - pad * 2); // 2. 五角星章面正中间 int starCenterX size / 2; int starCenterY (int) (size * 0.50); int outerR (int) (size * 0.20); int innerR (int) (outerR * 0.382); Path2D star new Path2D.Double(); for (int i 0; i 10; i) { int angleDeg -90 i * 36; double rad Math.toRadians(angleDeg); int r (i % 2 0) ? outerR : innerR; double x starCenterX r * Math.cos(rad); double y starCenterY r * Math.sin(rad); if (i 0) star.moveTo(x, y); else star.lineTo(x, y); } star.closePath(); g.fill(star); // 3. 环绕公司名沿圆周排布 Font font loadChineseFont(size); g.setFont(font); String companyName 某某电子合同服务有限公司; // 从正上方开始按 88 度总弧度排布 drawArcText(g, companyName, size / 2, size / 2, (int) (size * 0.36), -90.0, 88.0); // 4. 中央横排文字 String centerText 合同专用章; FontMetrics fm g.getFontMetrics(); int textW fm.stringWidth(centerText); g.drawString(centerText, (size - textW) / 2f, size / 2 size * 0.15f); g.dispose(); ImageIO.write(image, png, new File(seal.png)); System.out.println(seal.png generated, size size); } /** 跨平台的环绕文字绘制方法 */ private static void drawArcText(Graphics2D g, String text, int centerX, int centerY, int radius, double startAngleDeg, double totalSpreadDeg) { FontMetrics fm g.getFontMetrics(); int n text.length(); double step n 1 ? 0 : totalSpreadDeg / (n - 1); double angle startAngleDeg - totalSpreadDeg / 2.0; for (int i 0; i n; i) { double rad Math.toRadians(angle); double x centerX radius * Math.cos(rad); double y centerY radius * Math.sin(rad); // 先把坐标系平移到圆周上的目标点 g.translate(x, y); // 旋转角度让单字的底部朝向圆心 g.rotate(Math.toRadians(angle 90)); int charW fm.charWidth(text.charAt(i)); int charH fm.getAscent() - fm.getDescent(); g.drawString(String.valueOf(text.charAt(i)), -charW / 2.0f, charH / 2.0f); // 恢复坐标系继续下一个字 g.rotate(-Math.toRadians(angle 90)); g.translate(-x, -y); angle step; } } /** 优先从外部字体文件加载避免服务器上缺中文字体导致豆腐块 */ private static Font loadChineseFont(int size) throws Exception { // 如果classpath下有fonts/simhei.ttf优先使用 try (var in SealGenerator.class.getResourceAsStream(/fonts/simhei.ttf)) { if (in ! null) { return Font.createFont(Font.TRUETYPE_FONT, in) .deriveFont(Font.PLAIN, size * 0.11f); } } return new Font(宋体, Font.PLAIN, size * 0.11); } }环绕文字排布是这枚印章能不能“成型”的关键。代码里的思路是以圆心为参考点把每个字符当成一个独立小块算出它在圆周上的角度。g.translate(x, y)先把画笔移到该字符应出现的位置g.rotate(Math.toRadians(angle 90))让这个字符旋转到“底边朝向圆心”的姿态最后drawString绘制时把坐标往左、往上各偏移半个字符的宽高保证视觉中心精确落在圆周上。这里angle 90是经验方向角度增减正负号不同字可能会底朝外、头朝外或者颠倒在实际集成时先用一句话文本打印角度值单步调试是最高效的手段不必死记公式。五角星的内半径取了外半径的0.382倍也就是黄金分割比例在五角星几何关系里的真实数值。这样画出来每一笔的凹角都恰好落在标准位置而不是随意写一个innerR * 0.5让星星看起来“发胖”。如果直接用fillPolygon也可以但Path2D.Double在缩放、平移时精度更好后续如果要加圆角星、双层章还能继续复用。2.3 从圆章到椭圆章/数字签章改一组参数就够很多合同场景里要的不是正圆而是椭圆形的“骑缝章”或者是带边框的数字签名章。圆章改为椭圆章不需要重写绘制逻辑把drawOval(pad, pad, size - pad * 2, size - pad * 2)改成drawOval(pad, (int)(size*0.15), size - pad*2, (int)(size*0.7))再把环绕半径从均匀的radius改成radiusX和radiusY两组值字符坐标按椭圆参数方程x centerX radiusX * cos(rad)、y centerY radiusY * sin(rad)计算即可。数字签名章则把五角星替换成姓名文字把“合同专用章”替换为日期整体绘制逻辑不变。核心收益是参数驱动的画章模型可以复用到所有章型后续调整章面样式只需改参数不用换实现代码。3. 把印章合成进PDFPDFBox与iText选型、坐标换算3.1 选型PDFBox适合盖章合成iText更适合证书级签名把画好的PNG印章合成到电子合同PDF主流的Java方案是PDFBox和iText我遇到的大多数自研电子合同模块最终都选了PDFBox。原因很直接PDFBox是Apache开源协议对商用集成无附加限制API设计简单加载PDF、加图片、保存三个动作就能完成盖章。iText在PDF生成能力上更丰富PDF 2.0规范和数字签名支持也更完善但它的AGPL协议对内部商用系统有合规要求而且很多历史版本接口差异较大社区里针对旧版的教程可能要改几行才能跑。这里列出二者对比如果是“把已有合同PDF盖个章”PDFBox最省心如果要连“数字签名证书链时间戳”一起做再看iText或直接接第三方电子签服务。我下面的代码以PDFBox 2.0.x为例在2.x和3.x上API大体一致。对比项PDFBoxiText开源协议Apache 2.0商用友好AGPL商用需评估合规条件核心能力加载/创建/编辑PDF图片合成同样支持且提供更完整的PDF标准能力数字签名支持基础签名需配合额外库签名栈更成熟但版本组合复杂社区资料多踩坑记录好搜多但版本差异大最适用场景电子合同视觉盖章、合并、拆分证书级PDF签名、复杂排版生成3.2 用PDFBox合成印章的最小可运行路径安装依赖的方式就不展开了Maven工程里引入org.apache.pdfbox:pdfbox:2.0.30这类坐标即可。下面这段代码演示了如何把刚才生成的seal.png合成到一份已有合同的指定位置并把结果另存为新文件。import org.apache.pdfbox.pdmodel.PDDocument; import org.apache.pdfbox.pdmodel.PDPage; import org.apache.pdfbox.pdmodel.PDPageContentStream; import org.apache.pdfbox.pdmodel.graphics.image.PDImageXObject; import java.io.File; public class SealPdfAppender { public static void main(String[] args) throws Exception { File srcFile new File(contract.pdf); File outFile new File(contract-sealed.pdf); try (PDDocument doc PDDocument.load(srcFile)) { PDPage page doc.getPage(0); // 默认盖第一页 float pageWidth page.getMediaBox().getWidth(); float pageHeight page.getMediaBox().getHeight(); PDImageXObject sealImg PDImageXObject.createFromFile(seal.png, doc); // PDF单位是pt1pt1/72英寸。PNG像素按72dpi换算成pt float sealW sealImg.getWidth() / 72f * 30f; float sealH sealImg.getHeight() / 72f * 30f; // 常见的落款位置右下角距右边距36pt距下边距36pt float x pageWidth - sealW - 36; float y 36; try (PDPageContentStream cs new PDPageContentStream( doc, page, PDPageContentStream.AppendMode.APPEND, true, true)) { cs.drawImage(sealImg, x, y, sealW, sealH); } // 关键content stream关闭后再保存 doc.save(outFile); } System.out.println(sealed PDF saved: outFile.getAbsolutePath()); } }PDImageXObject.createFromFile负责把PNG解码成PDF可用的图像对象这里会保留PNG的透明度通道。sealImg.getWidth() / 72f * 30f的含义要解释清楚PNG的像素密度换算到PDF pt单位时默认以72dpi为基础乘30f是放大系数相当于把印章渲染成1英寸印出来更大一些的高清版本。如果你希望章的大小正好是3厘米那么计算公式是pixelWidth / 72f * 2.54f * 3但实战中更快的做法是先跑一版量一下PDF里实际盖子的大小再微调。PDPageContentStream一定要放在try-with-resources里并在doc.save()之前关闭。这是新手的头号翻车点没有关闭内容流就去保存生成的PDF文件底层会缺少结束标记部分阅读器可以打开但打印或者上传到第三方合同平台时会被判定为“文件损坏”。用AppendMode.APPEND是叠加内容而不是重写页面这样原有合同上的文字、签名、表格都不会被破坏。3.3 坐标不是玄学换算左上角页面坐标与PDFUnitPDFBox的坐标原点在页面左下角x轴向右y轴向上这和Web页面设计器里“原点在左上角、y轴向下”的习惯完全相反。很多自研合同系统的盖章坐标都来自前端需求前端画布上用户拖了一个印章到某个位置后端拿到的坐标通常是相对页面左上角的偏移。要转成PDFBox坐标只需要一套简单换算float pdfX designX; // 设计稿与PDF的x方向一致 float pdfY pageHeight - designY - sealH;这里的designX、designY是设计稿里印章左上角的坐标sealH是印章在PDF里的高度pt值。另一个容易踩的点是页面旋转有些合同扫描件page.getRotation()返回90或270这时候坐标换算还要额外考虑旋转角度否则章会盖到页面的另一个象限里。我的习惯是处理任何外部上传的PDF之前先打印page.getMediaBox()和page.getRotation()用一条日志确认页面基本属性再决定坐标公式花一分钟省掉一小时的调试时间。4. 电子签章的核心参数调优文字、颜色、尺寸与字体4.1 参数对照表从模糊到清晰的调参顺序画章代码不是能跑就行把参数调到“像真实盖上去的”才是验收标准。我整理了一份按优先级排列的参数清单每次调章都从这张表开始。参数建议值范围作用失败表现画布size600900越高越清晰低于300出图后边缘毛糙TYPE_INT_ARGB固定保留透明背景用RGB类会出白底主色alpha220245模拟印泥轻重255太“死”低于200太淡外圆strokesize/40size/30外圈粗细太细像签字太粗像儿童章字体加载外部字体优先跨平台不乱码系统无字体出方块环绕文字总跨度80度110度文字分布密度跨度太大字散太小拥挤五角星外半径size0.20size0.24中心视觉占比太大遮字太小没章感调参有个顺序毒点很多人先改颜色再改字号最后发现章是模糊的又回头改画布大小导致前面参数全部要重调。我建议严格按“画布大小 → 透明度 → 字体加载 → 文字布局 → 位置坐标”的顺序来前面的参数定了之后就不要再动每一层的验证结果才可比较。4.2 字体加载跨平台部署的中文字体方案本地开发时new Font(宋体, ...)通常没问题因为开发机Windows或macOS里都有中文字体但到了纯净的Linux服务器上JVM找不到“宋体”降级渲染出来的字符会变成一个个豆腐块。这个问题在“本地好好的一到服务器就翻车”的排障记录里长期霸榜。常见做法是准备一个开源的中文字体文件放进src/main/resources/fonts/目录下代码启动时用Font.createFont(Font.TRUETYPE_FONT, in)加载再用deriveFont设置字号。我上面那段loadChineseFont已经写了这个逻辑先尝试从classpath加载字体文件失败才回退到系统字体这样同一个Jar包在Windows开发机和Linux服务器上行为一致。要注意Font.createFont返回的字体基础大小是1pt直接drawString会画出蚂蚁大小的文字。必须用deriveFont(Font.PLAIN, size * 0.11f)派生出一个明确字号这里的size * 0.11f对应章面文字约占整个画布宽度的11%是我在600900画布上测试比较舒服的经验值。字体文件本身不要重复加载建议用静态变量缓存一次Font对象避免每次生成印章都重新解析字体文件。4.3 多倍渲染解决高清屏下印章发虚如果生成的印章在普通电脑上看还行但在高分屏或打印预览里发虚问题通常不是代码逻辑而是画布分辨率不够。解决思路其实很简单把画布尺寸从最终显示尺寸的1倍提升到2倍或3倍再把PNG按比例缩小合成到PDF。比如最终显示宽度是160px那就用size 480去绘制导出后合成时把宽度控制在160px相当于让每个物理像素点获得3倍的采样密度。这就是多倍渲染效果比单纯开抗锯齿明显得多尤其是外圆和五角星这样的几何图形在高倍率下边缘会非常平滑。代价只是内存中一个几百像素的BufferedImage对现代服务器来说可以忽略。5. 电子签章落地避坑我踩过的5个真实问题5.1 合成后印章变成白底方块盖住了合同正文现象章画好了PDFBox合成也没报错但打开PDF发现印章位置是一块白色矩形红色章边线被白底吞掉了。原因BufferedImage的类型写成了TYPE_INT_RGB这个类型没有Alpha通道Java2D绘制时默认背景是黑色ImageIO导出PNG后透明区域被硬编码成了不透明颜色。另一种情况是画布初始化后没有清空背景默认的Graphics2D背景是全黑的合成时黑底被当成图像内容。解决创建图片时使用BufferedImage.TYPE_INT_ARGB并且在绘制前主动清空像素g.setComposite(AlphaComposite.Clear); g.fillRect(0, 0, size, size); g.setComposite(AlphaComposite.SrcOver);。这两行保证了画布所有像素点的Alpha为0后续绘制的印章图形叠加在完全透明的画布上导出PNG才是真正背景透明的图。5.2 Linux服务器上生成的中文字变成豆腐块现象同样的代码在开发机产出正常的“合同专用章”部署到Linux服务器后章面文字变成了方框一个汉字占一个方块。原因服务器的JRE运行时找不到中文字体AWT字体解析回退到一个没有任何字形映射的默认字体。这和代码里new Font(宋体, ...)没有关系因为“宋体”只是逻辑字体名真正渲染时要靠系统字体库支撑。解决按2.2节做法把字体文件打进Jar包启动时用Font.createFont加载并在加载后立刻用deriveFont设置字号然后用一个静态字段缓存。部署后可以在服务器上跑一个独立测试类直接输出Font.createFont加载的字体名称以及GraphicsEnvironment里注册的字体列表确认字体被识别再集成到正式链路里。5.3 环绕文字方向/位置与人眼预期恰好相反现象环绕的公司名称文字是倒着排的或者首字出现在左下角而不是正上方。原因Graphics2D的旋转方向与常规数学坐标系在视觉上存在差异y轴向下让正角度旋转看起来是顺时针再加上startAngle的起点定义不同同一个参数在不同JDK版本下的表现也没有保证。解决先在drawArcText方法里把每个字符的角度值打印出来确认首字的angle是否为-90度附近然后把g.rotate(Math.toRadians(angle 90))改成g.rotate(Math.toRadians(angle - 90))观察文字朝向变化。这一步没有任何数学公式能替代实际运行验证因为不同的坐标定位写法会改变最终的视觉方向跑一次看一眼比对之后再固定参数。5.4 PDFBox盖完章后PDF文件损坏现象代码执行成功输出文件也存在但Adobe或其他阅读器打开时提示“文件已损坏需要修复”或者上传到合同平台时被拒。原因最常见的是PDPageContentStream没有关闭就调用了doc.save()。PDF内部对象结构需要正确关闭内容流才能写出完整的xref表未关闭时文件缺尾部交叉引用表阅读器会尝试修复但严格校验的服务端会直接拒绝。解决严格按照3.2节代码里的嵌套try-with-resources内层先关闭PDPageContentStream再执行doc.save()最后关闭PDDocument。另外还要确认PDFBox版本与JDK版本的兼容性比如JDK17跑PDFBox 1.x就会遇到模块化访问报错升级到PDFBox 2.0.25即可。5.5 章边缘有白边放大看像劣质抠图现象印章在普通缩放比例下正常放大两倍后红色笔画周围有一圈白灰色的细边。原因这是典型的图形边缘抗锯齿问题。绘制红色线条时边界像素的Alpha值介于0到255之间导出PNG后这些半透明像素在浅色PDF背景下与白色底叠出浅色边。另一个来源是保存过程中用ImageIO.write(image, jpg, ...)二次转码JPEG压缩会丢失Alpha通道并自动补白底。解决始终坚持导出PNG格式禁止在中间环节把印章图转成JPEG如果白边依然明显可以在绘制前把整个画布统一乘以一个半透明的红色混合层让边缘透明度整体下降而不是单独处理某个像素。最简单有效的一招还是多倍渲染用3倍尺寸绘制再在PDF合成时缩小边缘透明度过渡会更均匀白边几乎肉眼不可见。6. 把章做得更像“盖上去的”验证方案与一个实用技巧代码层面能画章、能盖章之后还要过一道“验收关”这枚电子签章和扫描一份纸质合同盖出来的视觉效果相比会不会显得“太新”这里有两个思路。第一个思路是加“油墨纹理”。真实印章盖在纸上印泥不可能绝对均匀放大看会有细小的颗粒和不规则深浅。实现方式是在印章绘制完成后遍历画布像素对印章区域内的像素按概率叠加细微的亮度扰动比如每20个像素中随机取一个将其Alpha值减少5到10。这个扰动强度不能大否则章看起来脏。实现简单但能让视觉上“PS感”一下子淡很多。我一般建议客户验收之前先加上这个效果因为纯色块印章在合同白底上显得太“塑料”这是最常被业务方挑出来的毛病。第二个思路是完善验证手段。章盖完后一定会有人问“这个章能不能防篡改”。坦白说自研Java生成的视觉印章本身不具备密码学级防篡改能力只能靠程序在合同关键区域生成内容摘要将来做校验时重新计算摘要比对。如果要上升到法律认可的电子签名常规路径是把PDF摘要、签名者身份、时间戳送到权威电子签名服务进行数字签名拿到带证书链签名结果再把章图与签名结果一起合成。这个环节建议直接对接成熟服务不在自家代码里从零实现信任链。我自己的流程图习惯是合同上传后记录原始PDF的SHA-256盖章完成后再次计算加密摘要并把两个摘要值追加到PDF的文档属性里。每一步产物都保留中间文件项目验收时能拿出“原始PDF、盖章PDF、摘要记录”三个闭环产物。最后一件事是我自己的血泪经验第一次做电子签章模块时我花了大量时间在印章的显示效果上结果业务方拿到手只说了一句“章太新了像是贴上去的”。从那以后我每次交付前都会把生成效果打印成PDF再用系统自带PDF阅读器放大到300%看边缘顺便让业务同事在普通缩放比例下看一眼整体视觉。印章好不好看最终是业务方说了算不是代码说了算。整套流程从生成、合成、验收到效果打磨跑通了电子签章这个功能才能在真实合同场景里站得住。希望帮到你。本文还有配套的精品资源点击获取
RELATED READING

延伸阅读

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