简介:面向C# WinForm初学者的iTextSharp PDF导出简易Demo,演示如何将文本框内容快速生成PDF文档,帮助理解Document、PdfWriter、Paragraph等核心对象的配合用法,从界面设计到文件保存覆盖一个完整的基础开发流程,非常适合刚接触桌面端PDF生成需求的开发者。压缩包共53个文件,大小4.17MB,包含C#源码、iTextSharp核心类库、项目配置文件、WinForm界面资源及可执行程序,目录结构清晰,方便直接加载解决方案进行编译运行与断点调试。示例覆盖从NuGet安装iTextSharp、创建WinForm界面、绑定按钮事件到实现ExportToPdf方法的完整流程,并专门指出初学者容易误用PdfImportedPage复制页面的错误,改为用Paragraph添加文本段落的正确做法,让代码逻辑更清晰,避免新手踩坑;同时还可进一步扩展图片、表格、页眉页脚等元素。目前已有1224人学习下载,适合快速上手,将基础能力迁移到报表导出、数据呈现等实际业务场景,也为后续使用iTextSharp丰富API、构建复杂PDF应用打下基础,同时可作为课程设计或小型工具的开发起点。
1. 为什么我从Html转PDF方案转向iTextSharp
1.1 一个上位机项目的真实需求
先说下我接手这个需求的场景。公司做工业检测设备的,上位机是C# WinForm,现场要给客户输出检测报告。一开始用的是前端同事留下的方案:本地起一个浏览器控件,把数据塞进HTML模板再调打印。这套方案有个让人头疼的问题——客户现场机器不一定装了对应版本的浏览器,而且页面样式在不同分辨率下缩放经常把表格挤得歪七扭八。每次客户说"报告格式不对",我得远程连上去看半天,最后往往是要么调CSS、要么重新截屏,效率极低。
后来客户提了个硬性要求:报告必须是PDF文件,能存档、能邮件发、能直接微信传给其他部门。我调研了一圈,发现核心需求其实很清晰:纯后台生成PDF文件,不需要用户干预;中英文混排;表格要规整;文件大小别太夸张;最好能直接嵌入数据曲线截图。当时我第一个想到的就是iTextSharp。市面上.NET生态里能生成PDF的库不少,但论成熟度和社区资料丰富度,iText系列绝对排得上号。
1.2 PDF生成的主流方案对比
先把我调研时对比的几个方案拉出来说,省得你们自己再去踩坑。我当时列了一个简单的表格:
| 方案 | 学习成本 | 中文支持 | 复杂表格 | 依赖环境 | 授权模式 |
|---|---|---|---|---|---|
| iTextSharp(5.x) | 中等 | 需注册字体,支持良好 | 强 | 无外部依赖 | LGPL/商业双授权 |
| PdfSharp | 低 | 支持较好 | 中等 | 无外部依赖 | MIT |
| Aspose.PDF | 低 | 开箱即用 | 强 | 无外部依赖 | 商业付费 |
| Html转PDF(浏览器控件) | 低 | 好 | 强 | 依赖浏览器组件 | 组件各有授权 |
| 原生PrintDocument绘制 | 高 | 好 | 弱 | 无外部依赖 | 免费 |
Aspose虽然好用但价格不菲,项目预算有限;PdfSharp做简单票据够用,但遇到复杂布局、页眉页脚、图片混排要写很多底层绘制代码;PrintDocument那套做过报表的人都知道,坐标算到怀疑人生。综合下来,iTextSharp在免费方案里是功能最全的,而且网上教程多——遇到问题一搜基本都有答案,这对被工期追着跑的人来说太重要了。
选型还有个关键考量:iTextSharp 5.x版本在NuGet上就能直接拉,原生支持C#,API设计也符合.NET开发者的习惯,不需要像Java的iText那样再包一层适配。所以最终定了iTextSharp,项目里实测跑通后,一直用到现在。
2. 环境准备:iTextSharp版本选择和第一个Demo跑通
2.1 用NuGet安装时的版本坑
这里必须提一个很多人栽过的坑。你先打开Visual Studio的NuGet包管理器,搜索iTextSharp,会发现列表里有iTextSharp和iText7(包名是itext7)两个主要的。iText7是iText集团后来推的.NET版本,API设计和5.x完全不一样,很多老教程里的PdfWriter、Document用法在7.x里变了。如果照着老教程敲代码却装成了7.x,第一步就卡住。
我做的是简单Demo,而且要最大程度复用网上的中文教程,所以选5.x的最后一个稳定版——iTextSharp 5.5.13.3。这个版本在NuGet上直接安装就行,命令也很简单:
Install-Package iTextSharp -Version 5.5.13.3或者直接在包管理器界面搜"iTextSharp",看到版本号5.5.13.3那个就是了。有几个旧版本还挂在列表里,像5.5.12、5.5.9,不建议用那些,可能会有已知Bug。
顺带说下,iTextSharp在5.x时代是LGPL授权的,免费使用,但如果你要打包成商业闭源软件对外分发,得仔细看一下授权条款。我们公司是自用内部系统,问题不大。如果是做商业软件卖给别人,我建议还是老老实实买商业授权,或者选MIT协议的PdfSharp,别在授权上留隐患。
2.2 10行代码生成第一个PDF文件
装好之后,找个WinForm项目,加一个按钮,双击进去写代码。我先给你看一个最小可运行的版本:
using System; using System.Windows.Forms; using iTextSharp.text; using iTextSharp.text.pdf; public partial class Form1 : Form { public Form1() { InitializeComponent(); } private void btnExport_Click(object sender, EventArgs e) { // 指定输出文件路径 string filePath = @"D:\test\simple.pdf"; // 创建文档对象,A4纸大小,左右边距40,上下边距40 Document document = new Document(PageSize.A4, 40f, 40f, 40f, 40f); try { // 创建PdfWriter实例,把文档对象和文件流关联起来 PdfWriter.GetInstance(document, new FileStream(filePath, FileMode.Create)); // 打开文档 document.Open(); // 添加一行文字 document.Add(new Paragraph("Hello iTextSharp!")); // 关闭文档 document.Close(); MessageBox.Show("导出成功!文件位置:" + filePath); } catch (Exception ex) { MessageBox.Show("导出失败:" + ex.Message); } } }这段代码的核心逻辑就四步:创建Document对象、创建PdfWriter、打开文档、往里加内容再关闭。我把文件路径写死了,你实际用的时候最好用SaveFileDialog让客户自己选保存位置,这个后面会讲到。
这里解释一下为什么需要PdfWriter:Document对象本身不负责生成PDF文件,它只是内容的容器,真正把文字、表格、图片"写"进PDF文件的是PdfWriter。你可以把Document想象成一块画布,PdfWriter是拿笔记录的人。这个设计在整个iTextSharp体系里到处都是——内容对象和输出对象分离,理解了这个,后面看API就不会晕。
跑通这个Demo之后,你会发现一个很现实的问题:Paragraph对象默认用的是Helvetica字体,不支持中文。如果你直接写document.Add(new Paragraph("中文测试")),生成的PDF里中文全是乱码。这就引出了第三部分要说的中文字体问题。
3. 实战需求:中文、表格、图片一个不能少
3.1 中文字体注册,根治乱码
做过iTextSharp的人基本都被中文乱码折磨过。乱码的根源在于PDF默认字体只覆盖拉丁字符集,要显示中文必须把系统里的中文字体手动注册进PDF文档。
最常用的方案是用系统自带的宋体或微软雅黑。注册代码是这样:
// 指定系统中文字体文件路径 string fontPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.Fonts), "simsun.ttc"); // 创建BaseFont对象,注册中文字体 BaseFont baseFont = BaseFont.CreateFont(fontPath, BaseFont.IDENTITY_H, BaseFont.NOT_EMBEDDED); // 用BaseFont创建iTextSharp的Font对象 Font chineseFont = new Font(baseFont, 12f, Font.NORMAL);注意几个关键参数:
BaseFont.IDENTITY_H表示用Unicode编码映射,这是中英文混排能正确显示的核心。如果你用其他编码,很可能还是乱码。BaseFont.NOT_EMBEDDED表示不把字体文件嵌入PDF。这样生成的PDF体积小很多,但换到没有这个字体的电脑上可能显示异常。如果是内部存档,问题不大;如果要发给客户或者打印,建议用BaseFont.EMBEDDED,代价是PDF文件会大几百KB。simsun.ttc的ttc后缀是Windows字体集合格式,iTextSharp支持这种格式。如果你偏好微软雅黑,路径换成msyh.ttc,用法一样。
每次导PDF都要写一遍字体注册代码很啰嗦,我的习惯是在项目里做一个静态类统一管理字体:
public static class PdfFontHelper { private static Font _normalFont; private static Font _boldFont; public static Font NormalFont { get { if (_normalFont == null) { _normalFont = CreateFont(12f, Font.NORMAL); } return _normalFont; } } public static Font BoldFont { get { if (_boldFont == null) { _boldFont = CreateFont(12f, Font.BOLD); } return _boldFont; } } private static Font CreateFont(float size, int style) { string fontPath = Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.Fonts), "simsun.ttc"); BaseFont bf = BaseFont.CreateFont(fontPath, BaseFont.IDENTITY_H, BaseFont.NOT_EMBEDDED); return new Font(bf, size, style); } }这样在业务代码里直接new Paragraph("设备名称", PdfFontHelper.BoldFont)就可以了,代码干净,也不会每次都去拿字体文件。
3.2 表格构建:PdfPTable和单元格样式
PDF报告里最常见的需求就是表格。iTextSharp的表格API我个人觉得设计得挺顺手,核心就两个类:PdfPTable和PdfPCell。
先看一个稍微复杂点的表格示例,我用的是设备检测报告的场景:
// 创建一个5列的表格,列宽按比例分配 PdfPTable table = new PdfPTable(5); table.WidthPercentage = 100f; // 表格占页面宽度100% table.SetWidths(new float[] { 1f, 2f, 1.5f, 1.5f, 2f }); // 设置表头行 string[] headers = { "序号", "检测项目", "标准值", "实测值", "判定结果" }; foreach (string header in headers) { PdfPCell cell = new PdfPCell(new Phrase(header, PdfFontHelper.BoldFont)); cell.BackgroundColor = BaseColor.LIGHT_GRAY; cell.HorizontalAlignment = Element.ALIGN_CENTER; cell.VerticalAlignment = Element.ALIGN_MIDDLE; cell.Padding = 5f; table.AddCell(cell); } // 添加数据行 for (int i = 0; i < dataList.Count; i++) { table.AddCell(new Phrase((i + 1).ToString(), PdfFontHelper.NormalFont)); table.AddCell(new Phrase(dataList[i].ItemName, PdfFontHelper.NormalFont)); table.AddCell(new Phrase(dataList[i].StandardValue, PdfFontHelper.NormalFont)); table.AddCell(new Phrase(dataList[i].ActualValue, PdfFontHelper.NormalFont)); PdfPCell resultCell = new PdfPCell(new Phrase(dataList[i].Result, PdfFontHelper.NormalFont)); // 根据判定结果设置不同的文字颜色 if (dataList[i].Result == "合格") { resultCell.BackgroundColor = new BaseColor(0, 200, 0); } else { resultCell.BackgroundColor = new BaseColor(255, 100, 100); } resultCell.HorizontalAlignment = Element.ALIGN_CENTER; table.AddCell(resultCell); } document.Add(table);这里有几个容易忽略的细节:SetWidths方法的参数数组长度必须和表格列数一致,否则会抛异常;AddCell是按行顺序填充的,5列表格添加了5个表头单元格之后,第6个单元格自动换行到第二行;单元格对齐方式有水平对齐(HorizontalAlignment)和垂直对齐(VerticalAlignment)两个属性,都要设,不然默认左上角对齐,看起来会很别扭。
还有个小技巧:如果表格内容太多,可以在PdfPCell构造时传入跨列数,例如new PdfPCell(phrase) { Colspan = 2 },这在做合并单元格时特别有用。
3.3 插入数据曲线截图(图片混排)
上位机报告里通常要贴数据曲线图,我的做法是把Chart控件或第三方曲线控件绘制的结果直接保存成图片,再塞进PDF。用iTextSharp的Image对象非常方便:
// 截取控件图像保存为图片 Bitmap btn = new Bitmap(chartControl.Width, chartControl.Height); chartControl.DrawToBitmap(btn, new Rectangle(0, 0, chartControl.Width, chartControl.Height)); btn.Save(@"D:\chart.png", System.Drawing.Imaging.ImageFormat.Png); // 将图片插入PDF Image chartImage = Image.GetInstance(@"D:\chart.png"); chartImage.ScaleToFit(520f, 280f); // 等比缩放到最大宽度520,最大高度280 chartImage.Alignment = Element.ALIGN_CENTER; document.Add(chartImage);ScaleToFit这个方法的内部逻辑是等比缩放,不会把图拉伸变形。需要注意因为PDF页面默认单位是点(point),1英寸等于72点,A4纸宽度大约是595点,所以图片最大宽度我设置520点,留出左右边距后正好合适。如果你用510以下的宽度,视觉上会显得图片偏小。
如果不想生成临时图片文件,也可以用MemoryStream的方式:
using (MemoryStream ms = new MemoryStream()) { btn.Save(ms, System.Drawing.Imaging.ImageFormat.Png); Image pdfImage = Image.GetInstance(ms.ToArray()); pdfImage.ScaleToFit(520f, 280f); document.Add(pdfImage); }这样可以避免临时文件残留,在反复导出测试的时候特别省心——不然临时文件夹里全是chart_1.png、chart_2.png这种垃圾文件。
4. iTextSharp导出PDF必踩的坑
4.1 字体文件缺失与字体路径写死的问题
我第一版代码是直接把字体路径写死的:"C:\\Windows\\Fonts\\simsun.ttc"。在自己机器上跑得好好的,结果部署到客户现场的工控一体机上,导出PDF全是方块乱码,折腾了半天才发现那台机器系统瘦身过,宋体文件被精简掉了。后来我换成用Environment.GetFolderPath(Environment.SpecialFolder.Fonts)动态获取系统字体目录,再用File.Exists判断文件是否存在,不存在就回退到微软雅黑,再不行就弹提示让客户装字体,这个问题才算彻底解决。
还有个要注意的点:如果你决定用EMBEDDED模式把字体嵌进去,生成的PDF文件体积会显著变大。有一种更优雅的方案是只嵌入用到的字符子集,但iTextSharp 5.x对中文子集嵌入的支持比较弱,实测下来反而容易出问题。我的建议是内部系统用NOT_EMBEDDED就好,PDF文件才几十KB,发给客户如果对方显示不正常,再考虑转成图片版PDF。
4.2 文件被占用和流未关闭的问题
初学的时候很容易这么写:
FileStream fs = new FileStream(filePath, FileMode.Create); PdfWriter.GetInstance(document, fs); document.Open(); // ... 添加内容 document.Close(); fs.Close();如果中间某个环节抛异常,document.Close()和fs.Close()都不会执行,文件就一直被进程占用。下次再点导出按钮就会报"文件正在被另一个进程使用"。正确写法是包在using或者try-catch-finally里:
Document document = null; try { document = new Document(PageSize.A4, 40f, 40f, 40f, 40f); using (FileStream fs = new FileStream(filePath, FileMode.Create)) { PdfWriter.GetInstance(document, fs); document.Open(); // ... 添加内容 } } catch (Exception ex) { MessageBox.Show("导出失败:" + ex.Message); } finally { if (document != null && document.IsOpen) { document.Close(); } }注意using块结束时fs会ReleaseComObject并关闭文件句柄,document在finally里保证必定关闭。这样即使中间抛异常,文件流也能被释放,下次再导出不会被锁住。
另外一个隐藏问题是:Document对象关闭之后,不能再重新打开往里面加内容。如果需求是"一份报告多个模块分别生成后再合并",要用PdfCopy或PdfImportedPage的方式去处理,这块本文不展开,但你先有个概念——Document是单向的,开-写-关,闭环。
4.3 表格跨页时表头不重复的尴尬
表格数据一多,自动分页是好事,但默认情况下iTextSharp把PdfPTable当作一个整体内容来排版,跨页之后第二页不会自动重复表头。客户拿到一份5页的报告,从第2页开始就不知道每列代表什么了,非常不专业。
解决方式很直接,设置表头的重复行数:
table.HeaderRows = 1; // 前1行作为表头,跨页时自动重复这个属性可以在创建表之后立刻设置。如果你有合并表头的需求,比如第一行是大分类、第二行是字段名,就设置HeaderRows = 2。不过要注意的是,设置HeaderRows之后,表示这些行只会出现在每一页开头,不会出现在表格末尾,如果你的第一行有大合并单元格(跨列),某些情况下跨页显示会有点怪异,这时候建议你测试一下实际效果。
还有个分页时的排序问题:在遍历大量数据行添加单元格时,我习惯每满20行就手动调用document.Add(table)然后新建PdfPTable继续填,这样控制每页的数据行数,避免表格行被拦腰截断。这个技巧在导出几百条检测记录的时候特别关键,效果比自动分页整齐得多。
5. 从Demo到能用的进阶思路
5.1 用事件给每页加页眉页脚和页码
基础Demo跑通后,下一个需求大概率是"帮我在每页底部加上页码,页眉放公司logo和报告编号"。iTextSharp里实现这个功能是通过PdfPageEventHelper这个事件类,它不像Html里有现成的header footer标签,需要自己写:
public class PageNumberEventHandler : PdfPageEventHelper { private Font _footerFont; public PageNumberEventHandler(Font footerFont) { _footerFont = footerFont; } public override void OnEndPage(PdfWriter writer, Document document) { PdfContentByte cb = writer.DirectContent; string text = "第 " + writer.PageNumber + " 页"; float textWidth = _footerFont.GetCalculatedBaseFont().GetWidthPoint(text, _footerFont.Size); // 在页面底部居中显示页码 cb.BeginText(); cb.SetFontAndSize(_footerFont.GetCalculatedBaseFont(), _footerFont.Size); cb.SetTextMatrix((document.PageSize.Width - textWidth) / 2, 20f); cb.ShowText(text); cb.EndText(); } }用的时候注册到PdfWriter上,一句代码搞定:
PdfWriter writer = PdfWriter.GetInstance(document, fs); writer.PageEvent = new PageNumberEventHandler(PdfFontHelper.NormalFont);这样每一页结束的时候都会触发OnEndPage方法,往页面底部写页码。你也可以在同一个事件类里重写OnStartPage方法画页眉,原理一样。唯一要注意的是GetWidthPoint算文字宽度这个细节——中文字符宽度用默认的12磅宋体是能算准的,但如果字体没注册成功,这里会返回一个错误值导致页码位置偏移,所以务必保证字体注册成功后再用这个方法。
5.2 SaveFileDialog代替写死路径和异步导出
Demo里写死文件路径没问题,实际项目里一定要用SaveFileDialog让用户选位置,同时把耗时操作丢到异步线程里,避免UI卡死。我最终的工具方法长这样:
private async void btnExport_Click(object sender, EventArgs e) { using (SaveFileDialog sfd = new SaveFileDialog()) { sfd.Filter = "PDF文件|*.pdf"; sfd.FileName = $"检测报告_{DateTime.Now:yyyyMMdd_HHmmss}.pdf"; if (sfd.ShowDialog() == DialogResult.OK) { btnExport.Enabled = false; btnExport.Text = "正在导出..."; try { await Task.Run(() => GeneratePdf(sfd.FileName)); MessageBox.Show($"导出成功!\n文件位置:{sfd.FileName}", "提示"); } catch (Exception ex) { MessageBox.Show("导出失败:" + ex.Message, "错误"); } finally { btnExport.Enabled = true; btnExport.Text = "导出PDF报告"; } } } } private void GeneratePdf(string filePath) { // 上述所有构建文档内容逻辑放在这里 }异步导出这块有个容易被忽略的坑:如果在后台线程里访问了UI控件(比如读取DataGridView里的数据),会抛跨线程访问异常。正确做法是在进入Task.Run之前,先把数据集合快照出来传入方法,或者用Invoke回调UI线程取值。我习惯封装一个数据模型集合,把界面数据和PDF生成彻底解耦,这样导出方法本身也更容易写单元测试。
5.3 内存流方式输出到别处
除了生成文件,iTextSharp还可以直接输出到MemoryStream,这就给"导出到指定位置"以外的场景留了空间。比如你把PDF输出成字节数组,然后直接上传到服务器,或者用Process.Start打开PDF阅读器预览:
MemoryStream ms = new MemoryStream(); Document doc = new Document(PageSize.A4); PdfWriter.GetInstance(doc, ms); doc.Open(); // ... 添加内容 doc.Close(); // 把PDF字节数组保存为临时文件并调用系统默认PDF阅读器打开预览 byte[] pdfBytes = ms.ToArray(); string tempFile = Path.Combine(Path.GetTempPath(), "preview.pdf"); File.WriteAllBytes(tempFile, pdfBytes); Process.Start(tempFile);这个方案我实测在客户现场也非常好用——有些客户电脑上PDF阅读器没关联好,双击文件没反应,用Process.Start直接调用默认程序大概率能弹起来。而且因为内容都在内存里,也没有文件占用、临时文件残留之类的问题。
我后来把导出模块做成了一个独立类库,所有业务系统共用一套PDF生成逻辑,字体注册、页眉页脚、页边距、图片缩放这些统统封装好。新项目里只需要拼数据、调方法,基本上半天就能搞定一个带完整格式的PDF报告导出功能。这就是从Demo到工程化的过程——先把一个简单功能跑通,再逐步把会变的部分抽成参数、把重复的部分收敛成工具类,最后你会发现,PDF导出不再是什么让人发怵的活,而是一个很标准的模块而已。
本文还有配套的精品资源,点击获取