简介:基于Spring Boot实现的电子合同电子签章生成方案,核心针对PDF格式合同,适合正在开发合同签署、文件盖章等功能的Java工程师。资源以zip压缩包形式提供,体积约72KB,具体文件清单与类型未在页面详细列出,但根据实现思路,会涉及Maven配置、Java源码、证书密钥以及签章图片等素材。项目借助JCA/JCE加密体系与Bouncy Castle等库实现RSA等非对称数字签名,通过iText或PDFBox读写PDF文档,并在指定坐标插入签章图像、绑定签名信息,使合同内容不可篡改且可验证来源。整体代码结构清晰,包含pom.xml依赖管理、业务逻辑层与资源目录,可帮助开发者理解从密钥生成、签章绘制到PDF写入的完整流程,同时了解Spring Boot如何以微服务方式集成这一能力。目前已有1249人学习浏览,适合需要快速搭建企业级电子签章功能的团队或个人参考。
1. 电子合同里的电子签章:为什么说它不是画一个印章图片那么简单
做电子合同系统的人,大概率都遇到过这个需求:客户拿着纸质合同扫描件说“帮我把公司章盖上”,或者在已经生成的 PDF 上要盖一个红章。如果你只是用 Java 在图片上画个红色圆形,然后贴到 PDF 上,那你只做完了 20% 的活。真正的电子签章,要解决的不是“看起来像章”,而是“这章是谁盖的、盖完之后有没有被改过、换台电脑打开会不会显示异常”。这三个问题,分别对应视觉签章、数字证书签名、PDF 规范兼容性。本文不会假装某个开源项目就是标准答案,而是把从业者最常见的方案讲透:用 Java 生成符合 PDF 规范的电子签章图片,再通过 PDF 操作库把它按页面坐标精确盖入合同文件,并处理好印章换电脑打开跑偏、打印变黑、签名无效这一类实战问题。适合正在自研或集成电子签章功能的 Java 工程师,也适合准备在合同模块引入签章能力的架构师。
2. 从需求到实现:先拆电子签章的类型与合规边界
2.1 电子签章和电子签名不是一回事
很多刚接触这个领域的人会把电子签名和电子签章混为一谈。电子签名是数学层面的概念,核心是私钥签名与公钥验签;电子签章则是可视化结果加签名数据的结合体:页面上能看到一个红章,章体内部或 PDF 元数据里藏着签名信息。在 Java 里生成电子签章,可以只做视觉层——画一个圆形红章贴到 PDF 上,这是静态签章,防不了篡改;也可以做完整层——章图与数字证书绑定,每次盖章都附带签名值,这是合规电子签章。判断项目该做到哪一步,取决于合同交付场景。内部审批单、演示 Demo,用静态签章即可;对外具有法律效力的正式合同,则必须有数字签名支撑。
2.2 合规电子签章的几个硬性要求:防篡改、可追溯、视觉一致性
《电子签名法》里关于可靠电子签名的要求,落到技术实现上可以拆成四件事。第一,签名值必须覆盖合同原文,任何修改都会让验签失败;第二,签章者身份可验证,需要 CA 证书或企业内部证书体系;第三,签章时间可信,接入时间戳服务或自带可信时间;第四,签章在文档内随时可见且可提取。如果你的 Java 项目打算做合规签章,需要引入 PKI 相关能力,JDK 自带的 java.security 包能签数据,但 PDF 里的签名格式还得靠 PDF 库实现。如果你们公司没接 CA,只是想系统里有一个“具有操作留痕”的章,静态签章加数据库日志反而是性价比更高的选择。
2.3 Java 实现电子签章的三条主流技术路线对比
从 Java 生态看,生成电子签章有三条常见路线。第一条,直接用 PDF 库在 PDF 上绘制印章图形,配合 PDF 签名字段实现完整签名,这是后面章节将要展开的方案。第二条是预生成透明印章 PNG 图片,再通过 PDF 库作为水印或图形叠加到指定坐标,适合章样统一、不需要加密散列的场景。第三条是使用商用签章 SDK 或服务端 API,格式合规性由服务方保证,但引入外部依赖,合同数据和私钥管理都需要评估信任域。选型时关注三个核心维度:你手上有没有合规要求的证书、合同文件是否需要 PDF 以外的格式、以及你们能不能接受签名私钥存放在自己的服务器上。
2.4 电子签章的图片格式与图文约定:透明 PNG 与三要素
正式做之前,先把印章图片的规范定下来。行业内最常见印章格式是透明底 PNG,尺寸按标准五号章设计:圆形章外径通常为 42 毫米,在 300 DPI 下对应 496×496 像素。章面内容三要素:单位名称环绕排布、中间五角星或专用标识、底部编号或防伪码。由于 PDF 的坐标系统以磅为单位,一个 42 毫米的章换算出来约 119 磅,这个尺寸在 A4 页面上大约是页面宽度的 16%,视觉上比较协调。至于为什么不用 JPG,是因为 JPG 不支持透明通道,白底章盖到合同上会变成一个白块,非常难看。
// 印章尺寸常量定义示例:保持毫米、像素、磅三套单位换算 public class SealConstant { // 标准五号章外径 42mm,300 DPI 下像素尺寸 public static final int SEAL_SIZE_MM = 42; public static final int SEAL_SIZE_PX = (int) (SEAL_SIZE_MM / 25.4 * 300); // PDF 中以磅为单位:1mm ≈ 2.8346 磅 public static final float SEAL_SIZE_PT = SEAL_SIZE_MM * 2.8346f; }代码里把尺寸常量单独抽出来,避免后续在图片生成和 PDF 叠加时各自写一遍魔法数字。由于图片是按 300 DPI 绘制的,而叠加到 PDF 时要以磅为单位计算期望的物理尺寸,如果不处理这两种单位,会出现印章在屏幕上看起来正常,打印出来却偏小或偏大的情况。
3. 用 Java 画出合格的印章图片:Graphics2D 绘图与文字环绕
3.1 绘图底层的选择:为什么用 BufferedImage + Graphics2D
Java 生成印章图片,最直接的方式是使用 JDK 自带的 BufferedImage 与 Graphics2D。它不需要引入额外依赖,也能绘制圆、弧线、文字路径,足以支撑标准圆章的视觉表现。如果需求包含复杂元素,比如带防伪纹理、微缩文字、彩色渐变,Graphics2D 会显得吃力,这时可以考虑 Apache Batik 转 SVG 或者直接预置多套 PNG 模板。但从绝大多数合同场景出发,动态生成的单位名称各不相同,Graphics2D 按参数绘制反而比维护海量模板更高效。这里的核心设计是把章面内容与绘制逻辑解耦,单位名称通过参数传入,绘制时动态布局文字环绕角度。
3.2 文字如何环绕章面:弧度计算与逐字定位
圆形印章上单位名称通常沿上弧排布,从左侧约 60 度位置开始,绕过顶部到右侧约 120 度结束。实现文字环绕最简单的办法是逐字计算坐标和旋转角度,而不是一次性 drawString 整句。每个字的角度通过总弧度均分,先确定起始角度和结束角度,然后按字符数均分,每个字符的锚点落在圆弧切线上。需要注意中文标点与数字的宽度不一致,纯按字符数均分会大约有 3% 到 5% 的视觉误差,可接受的场景先按均分处理,强迫症场景可以改为按字符宽度动态分配弧长。
// 绘制沿圆弧排列的文本:逐字绘制并旋转 private void drawArcText(Graphics2D g2d, String text, double startAngle, double endAngle, int centerX, int centerY, int radius) { int charCount = text.length(); double angleStep = Math.toRadians(endAngle - startAngle) / (charCount - 1); for (int i = 0; i < charCount; i++) { double angle = Math.toRadians(startAngle) + angleStep * i; int x = centerX + (int) (radius * Math.cos(angle)); int y = centerY - (int) (radius * Math.sin(angle)); g2d.translate(x, y); // 切线方向旋转:上方文字需要保持“头朝圆心外” g2d.rotate(angle - Math.PI / 2); g2d.drawString(String.valueOf(text.charAt(i)), -g2d.getFontMetrics().charWidth(text.charAt(i)) / 2f, 0); g2d.rotate(-(angle - Math.PI / 2)); g2d.translate(-x, -y); } }这段代码的关键在于每次 translate 之后立即 rotate,绘制完成再反向旋转并平移回去,保证后续绘制状态不被污染。angle 减 PI/2 的原因是文字本身默认水平向右,弧上某一点的切线方向是角度方向,减 90 度之后才能让文字底部朝向圆心。绘制五角星类似,用 Path2D 按五个顶点坐标填充即可,五角星的中心要在章正中并依据单位名称长度做竖直微调。
3.3 图片抗锯齿、透明通道与锐利边缘:三个直接影响观感的参数
印章图片观感好不好,三个参数决定成败。第一个是抗锯齿开关,必须开启,否则文字和圆弧边缘出现明显锯齿。第二个是透明通道,目标图像类型用 TYPE_INT_ARGB,不是 TYPE_INT_RGB,否则背景是黑色方块。第三个是描边粗细,圆形边框建议用 6 到 8 像素的 Stroke 绘制,过小看起来像铅笔画的圈,过大则厚重得不像印章。此外红章颜色不要直接用纯红 255,0,0,常见公章红是暗红色调,比如 204,0,0 这类颜色,否则电子屏幕上经常出现刺眼的饱和度溢出。
// 创建印章图像:参数化返回透明 PNG public BufferedImage createSealImage(String companyName, Color sealColor) { int size = SealConstant.SEAL_SIZE_PX; BufferedImage image = new BufferedImage(size, size, BufferedImage.TYPE_INT_ARGB); Graphics2D g2d = image.createGraphics(); // 开启抗锯齿与文字抗锯齿 g2d.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); g2d.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_ON); // 透明背景 g2d.setComposite(AlphaComposite.Src); g2d.setColor(new Color(0, 0, 0, 0)); g2d.fillRect(0, 0, size, size); g2d.setColor(sealColor); // 外圆边框:Stroke 决定章的“厚重感” g2d.setStroke(new BasicStroke(6f)); g2d.drawOval(4, 4, size - 8, size - 8); // ... 后续绘制文字和五角星 g2d.dispose(); return image; }g2d.dispose() 很容易被忽略,尤其在高并发生成印章的服务里,不释放图形资源会导致内存中堆积大量原生资源,最终 Full GC 频繁甚至 OOM。绘制完成后的 BufferedImage 可以通过 ImageIO.write 输出为 PNG 文件,但如果只是临时盖到 PDF 上,可以直接转为 InputStream 交给 PDF 库。
3.4 字体加载的一个隐藏坑:服务端无中文字体
开发环境一切正常,部署到 Linux 服务器后印章上的单位名称全部变成方框,这是字体缺失的经典问题。Graphics2D 默认使用系统字体,服务器上没有中文字体时,文字无法渲染。解决方案不是依赖系统,而是把字体文件作为项目资源打包,典型的做法是将 simhei.ttf 或 simsun.ttc 放到 resources/fonts 目录,启动时通过 Font.createFont 加载并注册到 GraphicsEnvironment。
// 加载打包进 JAR 的字体文件,避免部署环境无中文字体 public static Font loadFont(String fontPath) throws Exception { try (InputStream fontStream = SealImageGenerator.class.getClassLoader().getResourceAsStream(fontPath)) { Font baseFont = Font.createFont(Font.TRUETYPE_FONT, fontStream); return baseFont.deriveFont(Font.PLAIN, 28f); } }字体文件尽量选择 TTF 格式,TTC 在某些 JDK 版本上 createFont 会抛异常。字体加载后建议把 Font 对象缓存到静态变量,否则每次生成印章都重新读文件,性能损耗明显。需要 CDN 或对象存储的团队,也可以把生成好的 PNG 印章缓存到远程,签章图片属于低频变动资源,同一个公司主体的章没必要每次生成。
4. 把章盖到 PDF 上:PDFBox 与 iText 的叠加大法与坐标换算
4.1 两种 PDF 库在“盖红章”这个场景上的真实差异
Java 操作 PDF 的主流库是 PDFBox 与 iText。对电子签章场景,它们的侧重点不同。PDFBox 是 Apache 开源项目,盖图方便,License 友好,但直接做数字签名的 API 相对繁琐。iText 是老牌库,对签名字段和数字签名的支持非常成熟,但商业版本有 AGPL 授权约束。如果团队只是做静态签章,优先选 PDFBox;如果预算允许且需要合规数字签名,iText 的 PdfSigner 能省不少事。大型合同系统常见的做法是混合使用:PDFBox 做坐标定位和预览,iText 做最终签名,两个库各自发挥优势。需要注意,同一个 PDF 先被 PDFBox 保存一次再被 iText 打开,可能导致原始 PDF 的某些结构发生变化,签名前要保持同一处理链路,避免来回倒手。
4.2 用 PDFBox 将印章图片盖到指定页面的具体实现
静态盖图的步骤是固定套路:加载 PDF、取出目标页、构造 PDImageXObject、设置变换矩阵、保存新文档。里面最关键的是坐标换算,PDFBox 的坐标系原点在页面左下角,x 轴向右,y 轴向上,而许多业务系统处理界面坐标时习惯左上角原点,直接把 UI 传入的坐标应用到 PDF 上,章会出现镜像错位。
// PDFBox 静态盖章:将透明印章按左下角坐标写入指定页 public void stampPdf(File pdfFile, File outFile, byte[] sealPng, int pageIndex, float x, float y, float width) throws IOException { try (PDDocument document = PDDocument.load(pdfFile)) { PDPage page = document.getPage(pageIndex); PDImageXObject image = PDImageXObject.createFromByteArray(document, sealPng, "seal.png"); try (PDPageContentStream contentStream = new PDPageContentStream( document, page, PDPageContentStream.AppendMode.APPEND, true, true)) { contentStream.drawImage(image, x, y, width, width); } document.save(outFile); } }方法里的 x、y 是印章左下角坐标,width 同时作为宽和高,因为印章必须等比缩放。调用方如果使用的是“页面高度减去视觉坐标”的 UI 坐标,必须先做转换:y_pdf = pageHeight - y_ui - sealHeight。PDPageContentStream 使用 APPEND 模式追加内容,这样不会覆盖 PDF 原有内容流,多个章重复调用这个方法即可。如果印章要盖在整页最顶层,追加模式天然满足;如果印章需要嵌入在某段文本之下,就需要操作内容流的顺序,这已经属于高级定制范畴。
4.3 用 iText 实现带图层控制的盖章:与 PDFBox 的关键差异
iText 处理盖图的逻辑要区分“写在页面内容之上”还是“写在某一内容流之前”。PdfCanvas 直接写在页面内容尾部,视觉上覆盖其他元素。而对合规签章来说,更好的做法是把印章视觉部分与数字签名域分开:签名域可以透明,只有验证时可见。
// iText 7 在指定坐标写入图片并保留图层顺序 public void stampWithIText(String src, String dest, byte[] sealPng, int pageNum, float x, float y, float size) throws IOException { PdfDocument pdfDoc = new PdfDocument(new PdfReader(src), new PdfWriter(dest)); PdfPage page = pdfDoc.getPage(pageNum); PdfCanvas canvas = new PdfCanvas(page); ImageData imageData = ImageDataFactory.create(sealPng); canvas.addImageFittedIntoRectangle(imageData, new Rectangle(x, y - size, size, size), false); pdfDoc.close(); }这段实现里 Rectangle 的 y 坐标代表的是矩形底部,所以传入时 y 减去 size,否则章会显示在比预期高一条的位置。addImageFittedIntoRectangle 的最后一个参数是是否保持宽高比,传 false 时库会自动按比例缩放。如果用 iText 做合规签章,最终签的是 PDF 所有内容字节的摘要,盖图本身是否透明层并不影响签名有效性,但签名后仍对 PDF 做任何变更,验签都会失败,因此盖章和签名两个操作必须在同一次保存里完成。
4.4 坐标怎么算才对:页面尺寸、旋转与多页合同
办过多份合同的人知道,扫描版 PDF 的页面可能存在旋转属性,也就是页面显示方向和内容流坐标系不一致。PDFBox 读取 PDPage.getRotation() 返回 0、90、180、270 四类值,如果忽略旋转,盖章位置会偏移 90 度甚至跑出页面。比较稳健的处理方式是先根据页面宽高和旋转角度计算一个转换后的坐标盒。
| 页面旋转 | 视觉左上角对应的 PDF 坐标 | 换算方式 |
|---|---|---|
| 0° | (0, pageHeight - y - h) | 标准左下角换算 |
| 90° | (y, 0) | 宽高交换后按旋转方向映射 |
| 180° | (pageWidth - x - w, y) | 横纵反向 |
| 270° | (pageHeight - y - h, pageWidth - x - w) | 反向旋转后再映射 |
不要试图在每个盖章点都手动判断旋转,把这个逻辑封装成工具方法,输入业务坐标(页面左上角原点)和页面尺寸,输出 PDF 实际坐标。合同多页批量盖章时也走同一个方法,避免人工为每页单独换算,减少翻车概率。另外不要忽略 CMYK 色域的 PDF,打印时色域转换会让红色偏移,需要嵌入 ICM 或接受偏色的视觉差异。
5. 避坑与排查:电子签章生成的五个高频翻车现场
5.1 印章盖上后整块黑底,透明通道被 PDF 库丢弃
现象:代码生成的 PNG 在本地看图工具里一切正常,盖到 PDF 里变成黑色矩形,红章看不清楚。原因排查时,先想一下是这张 PNG 本身有没有真正写入透明通道。很多代码用 ImageIO.write(image, "png", output) 输出没有问题,但在用 PDFBox 创建 PDImageXObject 时,库对透明 PNG 的处理通常依赖图像的颜色空间。如果用了 createFromByteArray 且图像不带 Alpha 通道,或者 PNG 在内存中被 Graphics2D 转成了 TYPE_INT_RGB,透明信息就已丢失。解决:生成 BufferedImage 时显式使用 TYPE_INT_ARGB,写文件后重新读进来用 ImageIO.read 验证 getColorModel().hasAlpha()。另一种做法是在 PDFBox 里将透明 PNG 转为 mask 图像,但这会增加实现复杂度,不如从源头保证图片格式正确。
5.2 章盖到了合同背面或 PDF 文字下方
现象:需要盖在签名区上方,结果章出现在文字后面,或者被表单域覆盖。原因:PDF 内容流是有顺序的,直接 append 到页面末尾表示该图形在所有原有内容之上;但如果章被放在某个“已追加但更早提交”的内容流之后,或者页面上存在表单域且渲染优先级较高,就会出现覆盖关系异常。解决:确认使用 PDPageContentStream 的 APPEND 模式且构造参数 append true 而不是 false;若存在 AcroForm 表单,可以把章作为 Annotation 写入,或先把表单展平再盖。团队里如果有 PDF 渲染引擎不统一的情况,Firefox 内置预览和 Adobe Acrobat 对内容流嵌套的解析略有差异,同一个文件在两个软件里显示不一致,优先以 Adobe 效果为基准。
5.3 同一份 PDF 重复盖章越盖越大的叠影
现象:批处理将同一个章盖到多个合同,每次跑完发现章的位置出现重影,颜色越来越深。原因:原 PDF 文件被反复保存,每次 load 和 save 都会在文件里追加新的增量更新(incremental update),内容流也随之叠加,没有清除旧版本。解决:要么每次从原始文件流复制后另存为新文件,要么在保存时使用 document.setAllSecurityToBeRemoved 与压缩参数。更稳妥的方式是内存中只保留一份 PDDocument,所有页盖完再统一 save,不要在循环里反复 load。注意有些合同带有文档权限限制,PDFBox 默认会保留权限设置,但某些权限位会禁止内容变更,需要先确认文档允许编辑。
5.4 数字签名前置错误:先盖章后签名导致验签失败
现象:做合规签章时,先用 PDFBox 盖好图像再交给 iText 签名,结果签名验证失败,提示文档被修改。原因:PDF 的签名是对整个文档的字节摘要做的,任何在签名之后写入的字节都会破坏签名。盖章本身也是一种文档修改,因此必须先签名后盖静态章,或者在 iText 中把签名字段与章图在同一输出会话里完成。解决:如果业务要求“可见章与数字签名同时存在”,iText 的 PdfSigner 支持设置图层外观,把章图作为签名的视觉外观一并签入,这是合规实现中比较标准的做法。静态盖图和合规签名混用的时候,一定要梳理清楚前后顺序,这个坑坑过不少初入电子合同领域的人。
5.5 高并发批量盖章时内存暴涨
现象:一次批量给几千份 PDF 盖章,内存占用急剧上升,甚至触发 OOM。原因:每份合同 30 到 80 页不等,PDDocument 加载到内存后每页内容都保持在堆上,同时对 PNG 的 PDImageXObject 也持有图像像素数据,几千份累计起来非常可观。解决:逐份处理,每份处理完立即 close,不要维护一个巨大的 List 存放所有 PDDocument;印章 PNG 字节缓存一份全局复用,不要每份重新读文件。如果单份 PDF 页数多到离谱,还可以用 PDFBox 的增量解析模式加载,不过那是另一个层面的优化,一般业务系统不必强上。内存监控可以从 GC 日志入手,重点关注老年代增长曲线,判断是加载还是输出阶段引发的压力。
6. 电子签章生成后的进阶玩法:批量盖、防篡改自验与合同归档
合同系统中的电子签章做完基本盖图,一项容易被忽略的能力是“签章自验闭环”。自验包括两层:一是盖章后的 PDF 重新打开时识别章对应的签名数据是否有效,二是系统内部记录每一份合同签章操作的摘要和日志,形成可追溯凭证。自验不是给别人看的,而是自己系统在合同纠纷场景中可以拿出来证明“这一刻这个合同没被动过”的技术证据。
对于静态签章方案,可以反查 PDF 页面上某个区域的图像,对比与服务器保存的印章模板是否一致。API 层的做法是:盖章时记录合同 ID、页码、坐标、印章版本、操作时间、操作人,存入数据库;验章时按坐标截取 PDF 页面区域图像,与当前印章模板做灰度化后的相似度对比。这个方案实现成本低,能挡住大部分图片被替换、被擦除的情况,但挡不住对 PDF 内容流的恶意修改。需要更硬核的防篡改,则回到数字签名思路。这里给出一个轻量但实用的自验实现方向:盖章前计算合同原文 SHA-256,将摘要签入数据库,验章时重新计算原文摘要并比对。
// 合同原文摘要计算:盖章前与验章时使用同一逻辑 public static String digestContract(File pdfFile) throws IOException { try (InputStream in = new FileInputStream(pdfFile); DigestInputStream dis = new DigestInputStream(in, MessageDigest.getInstance("SHA-256"))) { byte[] buffer = new byte[8192]; while (dis.read(buffer) != -1) { // 循环读完即完成摘要更新 } return java.util.HexFormat.ofHexString().format(dis.getMessageDigest().digest()); } }这段代码的意义在于把摘要计算与文件读取合并,避免把整个 PDF 加载到内存再算摘要。盖章时计算一次摘要存库,归档前再算一次做比对,如果两次摘要不一致,说明合同在签章后发生了修改。这种做法不能证明修改发生在盖章之前还是之后,但能让系统在最早的时间点发现文件被意外改动。配合数据库事务记录,就形成一套低成本的内控追溯机制。
大批量盖章还有一个容易被低估的问题:输出文件的命名与目录组织。真实合同系统里,一份交易往往包含主合同、补充协议、附件清单多个文件,各自对应不同页面的不同章位。建议盖章服务接收批处理参数时,把每个盖章点拆成独立的盖章指令,用 JSON 数组传入,而不是写死在代码里。这样后续扩展。电子发票、电子回单、电子证明书等新文件类型的时候,服务层完全不需要改动,只要调用方按指令描述传入新坐标即可。
最后说一个团队协作里的习惯:所有电子签章相关的图像生成、坐标换算、PDF 叠加、摘要计算,尽量收敛到一个独立模块,不要散落在各业务代码里。我见过不少项目,章图在订单模块画一次、在账单模块又画一次,两处颜色和字体还略有偏差,后续统一更换章样时就特别狼狈。把印章生成器做成提供明确入参和返回值的服务组件,新人接手只需要读一个类的代码就能理解整体流程。如果你也正在做类似的事,建议尽早把这一步收拢起来,后面你会庆幸当初做了这个决定,希望帮到你。
本文还有配套的精品资源,点击获取