ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

C# 实现 TXT 转 PDF:编码、字体与分页的完整指南

C# 实现 TXT 转 PDF:编码、字体与分页的完整指南 写这篇东西之前我先说个真实场景。做上位机项目时经常需要把运行日志导出来客户点名要 PDF因为 PDF 方便存档、打印也不会像 TXT 一样被随手改坏。很多人的第一反应是“C# 做 TXT 转 PDF 还不简单读个文件再 Write 到 PDF 里不就行了”真动手才会发现这里头全是暗坑编码乱码、中文字体不显示、长行溢出、空行丢失、大文件内存爆炸。这篇文章我就从实际问题出发把整个思路、选型、完整代码和踩坑经验都摊开讲适合正在做上位机、桌面工具或内部系统的 C# 开发者。1. 先别急着写代码TXT转PDF的真正难点在哪1.1 TXT 不是单纯的字符串很多人把 TXT 当成一个可以随便字符串化的文件其实 TXT 最大的问题就是“没有标准”。同样是记事本保存的文本可能是 UTF-8、UTF-16 LE、UTF-16 BE、GBK、GB18030甚至老式系统里还能遇到 ANSI 和 OEM 编码。一个文件如果带着 BOMStreamReader 还能自动识别如果没带 BOM那就只能靠猜。猜错了解码出来就是满屏“锟斤拷”或者“口口口”。我见过最典型的例子程序在开发机上跑得好好的因为开发机上的文件都是 UTF-8 保存部署到客户现场后客户拿 Excel 或老系统导出的 GBK 文本直接传出乱码 PDF。所以第一步不是生成 PDF而是先把字节流还原成正确的 C# string。这个问题不解决后面所有步骤都在错误的地基上盖楼。1.2 PDF 没有自动排版引擎PDF 跟 Word 不一样它没有一个“流式排版引擎”来帮你处理换行、分页、段落间距。你在 PDF 页面里看到的每个字符、每条线、每个矩形都有明确的坐标。你告诉 PDF“这里有一个 Paragraph”它也只是按你给出的坐标和尺寸把内容画在指定区域。所以当我们想用 C# 把 TXT 转 PDF 时本质上要做的是三件事第一把文本切成可渲染的行第二根据页面尺寸计算每页能放多少行第三把每一行按字体、字号、行距画到对应的位置上。选对库可以省掉一部分手写排版但分页、字体、段落间隔仍然需要自己控制。如果直接一股脑把所有文本画在一页上生成的 PDF 内容会超出页面打印时被裁掉屏幕上看也乱七八糟。1.3 中文和字体是第一个拦路虎PDF 里的字体不是直接调用操作系统的字体而是通过字体描述文件把字形轮廓嵌入或者引用到 PDF 内部。C# 的 Graphics.DrawString 用系统字体画到屏幕上没问题但生成 PDF 时必须显式告诉 PDF 库“我用的是哪个字体文件”。此时如果直接使用默认字体往往只包含西文字符中文全变成空白或黑块。更麻烦的是中文字体文件体积普遍不小嵌入完整字库后 PDF 会变得很大。对于只有几页的日志转 PDF 需求还好如果一次转几百个文件每个文件都嵌入完整宋体或黑体体积会非常感人。理解这个问题的根源后你才能在做方案时选择合适的嵌入策略。2. 选型对比三套 C# PDF 库我最后留下谁2.1 iText7功能全坑也全iText 是 Java 社区的老牌 PDF 库后来出了 .NET 版 iText7到现在也是 C# 生成 PDF 最常用的方案之一。它支持段落自动换行、页边距、页眉页脚、表格、图片几乎你能想到的 PDF 功能都有。对于 TXT 转 PDF 这种需求它属于“杀鸡用牛刀”但正是因为它功能全某些边界情况反而更容易处理。它最大的坑是许可证。iText7 在开源协议下是 AGPL意味着如果你的工具是给公司内部用问题不大如果是放在商业软件里分发就必须考虑购买商业授权。很多小团队一开始不知道项目做完了才发现有授权风险只能临时换库。所以选型前一定要先确认使用场景。另一个坑是 API 抽象层次有点厚。你想简单渲染几行文字需要理解 PdfWriter、PdfDocument、Document、Paragraph、PdfFont 的概念。刚开始接触会觉得繁琐但反过来看这也是它的优势排版逻辑清晰不容易写出邋遢的代码。2.2 PdfSharp轻量但字体方案要给力PdfSharp 是一个老牌的 .NET PDF 库最新的 PDFsharp 6.x 已经支持 .NET 6用起来比 iText7 更贴近 GDI 的绘图思路。它适合“把文本绘制到指定页面区域”这种场景代码量也比较小。我早期做内部小工具时很喜欢用 PdfSharp因为它不需要考虑那么多概念画布、字体、坐标直接画就是了。但 PdfSharp 对自动分页的支持相对弱需要自己测量字符串高度、计算剩余空间、手动增加新页面。如果只是转一个几十行的 TXT 还好转几百上千行的日志时这部分分页逻辑写起来就有点麻烦。另外在新版 PdfSharp 里中文字体不再默认可用需要自己实现字体解析器这个曾让不少人在中文 PDF 上踩坑。如果项目里只有简单的英数文本PdfSharp 是首选一旦涉及中文我建议仍以 iText7 为主。2.3 QuestPDF适合重新排版不适合照搬 TXTQuestPDF 是近几年比较火的 .NET PDF 库主打类似 HTML/CSS 的流式布局上手体验比 iText7 好不少。它的 API 是声明式的你定义一个Document.Create(container {...})然后在里面设置 Column、Text、Padding最终自动分页写起来非常优雅。不过 QuestPDF 更适合处理结构化内容比如报表、发票、带标题的文档。对于 TXT 这种“本来已经排好行”的纯文本你反而需要先把每一行塞进一个 Text 组件本质上也是在逐行渲染。而且 QuestPDF 的旧版许可证变更过商业使用要留意版本和授权条件。如果只是想快速做一个小项目QuestPDF 也可以但为了减少变量我不把它作为首选。2.4 我的选型结论综合许可证、中文支持、分页能力和上手成本我实际项目里最常用的是 iText7。理由很简单中文处理最省心直接指定中文字体文件就能用。自带 Paragraph 自动换行长行不会横向溢出。自动分页不需要自己算行高和页面剩余空间。页边距、行距、字体大小都有成熟 API 可调。遇到复杂需求还能扩展表格、段落样式、页眉页脚不用推倒重来。如果你只是做一个一次性脚本不打算长期维护并且对英文文本没有中文要求用 PdfSharp 也够。但如果你要给客户做正式工具我还是建议尽早评估 iText7 的授权费用别等到上线前再折腾。2.5 为什么不建议用 Word Interop 或打印驱动还有一种做法是装 Office通过 COM 调用 Word 打开 TXT再用SaveAs2导出 PDF。这种方式在个人电脑上可行但放到服务器或工业现场基本就是噩梦需要安装 Office、需要桌面环境、会出现弹窗挂死、不同 Office 版本行为还不一样。打印驱动方案就更不用提依赖系统虚拟打印机程序一旦静默执行很容易卡在打印机队列里。用库生成 PDF 虽然要写点代码但可控性是天壤之别。3. 核心实现一个最简单的可运行转换器3.1 第一步正确读取TXT内容我建议先解决编码问题再考虑 PDF 渲染。下面这个辅助方法先读取文件前 64KB 的内容通过 BOM 判断如果没有 BOM则使用 Ude.NetStandard 这个 NuGet 包做编码检测。该包能识别 UTF-8、GB18030、Big5 等常见编码准确率比纯启发式高很多。using System.Text; using Ude; public static class TextFileHelper { static TextFileHelper() { Encoding.RegisterProvider(CodePagesEncodingProvider.Instance); } public static Encoding DetectEncoding(string filePath) { byte[] sample ReadSample(filePath, 64 * 1024); if (sample.Length 3 sample[0] 0xEF sample[1] 0xBB sample[2] 0xBF) { return Encoding.UTF8; } if (sample.Length 2 sample[0] 0xFF sample[1] 0xFE) { return Encoding.Unicode; } if (sample.Length 2 sample[0] 0xFE sample[1] 0xFF) { return Encoding.BigEndianUnicode; } try { var detector new CharsetDetector(); detector.Feed(sample, 0, sample.Length); detector.DataEnd(); if (!string.IsNullOrEmpty(detector.Charset)) { return Encoding.GetEncoding(detector.Charset); } } catch { // 检测失败时回退到 UTF-8 } return Encoding.UTF8; } private static byte[] ReadSample(string filePath, int maxBytes) { using var fs File.OpenRead(filePath); int length (int)Math.Min(fs.Length, maxBytes); byte[] data new byte[length]; int offset 0; while (offset length) { int read fs.Read(data, offset, length - offset); if (read 0) break; offset read; } return data; } }这里有一个容易被忽略的细节FileStream.Read 不保证一次读满你要的字节数所以需要用循环填满缓冲区。虽然本地磁盘大概率一次就读满了但代码交给别人维护时循环读更保险。3.2 第二步确定中文字体与边界接下来需要确定字体文件。在 Windows 上我常用黑体文件simhei.ttf它相比宋体更清晰而且它是单个 TTF不是 TTC 集合iText7 直接加载不会出现字体索引问题。如果客户机器没有这个字体也可以换成自己打包的思源黑体。字体路径在正式项目里最好做成配置文件不要硬编码。页面边界采用 A4 纵向页边距左右各 36 磅约 1.27 厘米上下各 36 磅。字号我一般用 10 磅行距用 1.35 倍这样中文日志默认看起来很舒服。如果内容更密集可以调到 9 磅但低于 9 磅打印出来就不太容易读了。3.3 第三步用Document逐行输出iText7 的 Document 会自动处理分页因此我们只需要逐行添加 Paragraph它会在页面写满时自动新开一页。空行也不能忽略我一般用单个空格代替使其仍然占据一个行高。using System.Text; using iText.IO.Font; using iText.Kernel.Font; using iText.Kernel.Geom; using iText.Kernel.Pdf; using iText.Layout; using iText.Layout.Element; public static class TxtToPdfConverter { public static void Convert(string txtPath, string pdfPath, string fontPath C:\Windows\Fonts\simhei.ttf, float fontSize 10f, float leading 1.35f) { Encoding encoding TextFileHelper.DetectEncoding(txtPath); using var reader new StreamReader(txtPath, encoding, false); using var writer new PdfWriter(pdfPath); using var pdf new PdfDocument(writer); using var doc new Document(pdf, PageSize.A4); doc.SetMargins(36, 36, 36, 36); PdfFont font PdfFontFactory.CreateFont(fontPath, PdfEncodings.IDENTITY_H, true); string line; while ((line reader.ReadLine()) ! null) { string content string.IsNullOrEmpty(line) ? : line; doc.Add(new Paragraph(content) .SetFont(font) .SetFontSize(fontSize) .SetMultipliedLeading(leading)); } } }这里用 StreamReader 逐行读取而不是一次ReadToEnd()主要考虑大文件场景。流式读取意味着内存占用稳定不管你转 1MB 还是 500MB 的 TXT内存基本不会随着文件增大而暴涨。3.4 完整代码示例把上面两段拼起来就是一个可直接运行的命令行工具。在 Main 方法里只需要传入路径using System; using System.IO; class Program { static void Main(string[] args) { if (args.Length 2) { Console.WriteLine(Usage: TxtToPdf input.txt output.pdf); return; } try { TxtToPdfConverter.Convert(args[0], args[1]); Console.WriteLine(转换完成); } catch (Exception ex) { Console.WriteLine(转换失败: ex.Message); } } }3.5 运行效果与验证转换完成后打开 PDF 检查三件事第一中文是否正常显示第二长行是否自动换行而不是横向溢出第三连续空行是否保留。如果这三项都没问题那这份代码已经能满足 80% 的“文本转 PDF”需求。剩下的 20% 是字体路径缺失、特殊字符越界、批量转换稳定性等边角问题。4. 中文编码和字体这里最容易被坑4.1 编码识别没有 BOM 的处理没有 BOM 的纯文本是最难搞的。我的建议是别自己做太多黑科技直接集成 Ude.NetStandard 这类检测库它能根据不同语言字符出现的概率推断最可能的编码。代码里已经写了只需要注意Encoding.GetEncoding(detector.Charset)前必须调用Encoding.RegisterProvider(CodePagesEncodingProvider.Instance)否则在 .NET Core 环境下拿不到 GB2312、GB18030 这些编码。还有一种情况是文件内容太少或全英文编码检测器会返回 ISO-8859-1 或者 Windows-1252导致中文文本被错误解码。遇到这种情况建议在工具界面上给一个“编码覆盖”选项让用户手动指定 UTF-8 或 GBK。工程上永远不要相信自动检测 100% 正确给用户留一个手动入口是最稳妥的。4.2 字体路径和TTF/TTC之间的区别Windows 里的中文字体通常是 TTC 格式比如 simsun.ttc、msyh.ttc。TTC 是把多个字体打包在一个文件里iText7 也能读但有时需要指定字体索引写起来比较绕。所以我个人更推荐用 simhei.ttf它是单一 TTF加载代码最简单。如果你的目标系统是 Linux一般安装fonts-noto-cjk后会有/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc这个文件也是 TTC直接用可能会有点问题最好使用 OTF 单字体版本或者对 TTC 做单独处理。提示不要把字体路径写死在代码里。建议做成启动参数或配置文件因为客户机器的 Windows 字体可能和开发机不完全一样。4.3 特殊字符、制表符和全角空格TXT 里经常会有制表符和全角空格。制表符在 PDF 里不会被自动扩展成固定宽度因为 PDF 没有“Tab 停靠位”的概念。如果在日志里遇到\t我建议不直接输出而是先把它替换成 2 到 4 个空格否则对齐效果会因为字体和字号不同而飘忽不定。全角空格可以从输入文本中直接保留中文字体会正确渲染全角空格。全角标点、引号、括号也没问题只要字体文件包含这些字形。黑体一般都能覆盖。对于罕见字符比如生僻汉字或特殊 emoji如果字库里没有PDF 里会显示成方框这个没有特别好的解决办法要么换更大字库要么提前用审查工具过滤掉。4.4 文本里的二进制垃圾怎么过滤有些日志文件里会混入控制字符比如\u0000、\u0002之类。这些字符不会显示但可能会让 PDF 渲染崩溃或者生成出不可预测的空白区域。我在项目里会先在读取时做一次清洗private static string CleanControlChars(string input) { var sb new StringBuilder(input.Length); foreach (char c in input) { if (c \t || c \n || c \r) { sb.Append(c); continue; } if (c 0x20 || c 0x7F) { continue; } sb.Append(c); } return sb.ToString(); }注意这段清洗是在识别编码之后做的因为StreamReader已经返回了合法的 .NET string控制字符是 U0000 到 U001F 范围内。不要把字节流先清洗那样容易误删多字节字符中的部分字节导致中文解码继续出错。5. 大批量文件时怎么办性能与稳定性5.1 不能一次性读入超大文件上一篇示例代码用逐行读取已经避开了大文件整体读入内存的问题。但如果你沿用File.ReadAllText再转 PDF遇到 200MB 的日志文件内存会瞬间飙到 500MB 以上。尤其在 32 位进程里OutOfMemoryException几乎是必然的。所以转换核心必须用流式读取。每次只处理一行PDF 文档对象会负责把已渲染好的行刷新到页面中。iText7 的 Document 内部会维护当前页状态当前页满了自动关闭并新建不会把整份 PDF 都在内存里拼完再写盘。5.2 内存与文件句柄代码里的using很重要尤其是 PdfWriter、PdfDocument、Document 这三层必须保证释放顺序正确。释放顺序是从内到外也就是 Document 先释放再释放 PdfDocument最后释放 PdfWriter。写成多个using在同一代码块中时C# 会按声明的逆序释放所以先声明 reader 和 writer再声明 pdf再声明 doc最终 doc 先释放这个顺序是没问题的。5.3 批量转换的异常隔离如果一次要转换几百个 TXT我强烈建议每个文件放一个 try/catch不要让一个坏文件中断整个任务。我在项目里会维护一个失败清单public record ConvertResult(string InputPath, bool Success, string Error); public static ListConvertResult ConvertAll( IEnumerablestring txtFiles, string outputDir) { ListConvertResult results new(); foreach (string txt in txtFiles) { try { string pdfPath Path.Combine(outputDir, Path.GetFileNameWithoutExtension(txt) .pdf); Convert(txt, pdfPath); results.Add(new ConvertResult(txt, true, null)); } catch (Exception ex) { results.Add(new ConvertResult(txt, false, ex.Message)); } } return results; }这样批量执行结束后还能生成一个汇总报告告诉客户哪些文件成功、哪些失败、失败原因是什么。这个体验比“弹个框告诉你转换失败”强太多了。5.4 实测性能数据参考我在一台普通 i5 办公机上测试过 10MB 左右的 UTF-8 日志逐行转成 A4 PDF耗时大概在 3 到 6 秒之间。速度受字体加载、页数、文本复杂度影响。如果生成的 PDF 非常大可以考虑压缩嵌入字体的选项或者使用字体子集化来降低 PDF 体积。iText7 对嵌入字体的子集化支持还是不错的默认情况下会自动做部分子集化实际的 PDF 体积不会像完整嵌入字库那么夸张。6. 落地到项目里上位机、WinForm 与后续扩展6.1 封装成工具类和后台任务不要直接把转换逻辑写在按钮点击事件里。我建议把Convert方法封装到一个独立类库中比如TextConvertService上层可以是命令行、WinForm、ASP.NET Core 接口也可以是上位机里的一个导出按钮。后期如果需求从“TXT 转 PDF”扩展成“TXT 转 PDF 并自动加水印”只需要在服务层增加配置不用动 UI 代码。6.2 在 WinForm 中调用并显示进度WinForm 里调用时如果文件很多一定要用异步或后台线程否则界面会卡死。简单的做法是Task.Run(() TxtToPdfConverter.Convert(...))在await之后更新进度条。但要注意iText7 的文本渲染逻辑是 CPU 密集型的并没有多少 UI 线程安全要求因此在后台线程用没有问题。批量转换时可以按文件数量分批刷新进度比如每个文件完成后ReportProgress一次。6.3 Linux 部署时的中文字体注意事项如果你的工具要部署到 Linux 服务器上除了库本身的依赖还要记得安装中文字体。很多精简版 Linux 镜像没装 CJK 字体生成的 PDF 中文会全部丢字形。我的建议是在 Dockerfile 里加RUN apt-get update apt-get install -y fonts-noto-cjk然后在代码中把字体路径指向/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc。如果用的是 TTC 文件iText7 加载时可能有索引问题最简单的办法是找一个 TTF/OTF 单字体文件放入应用目录通过配置文件指定路径。这样无论换哪台机器只要字体文件跟随程序走就不会出现中文方块。6.4 从 TXT 转 PDF 还能扩展什么完成了基础转换后可以做的扩展很多给每一页加页脚页码、把第一行当作标题加粗、把带有特定前缀的日志行标成不同颜色、把多个 TXT 合并到一个 PDF 并用书签标记来源文件、甚至支持从命令行拖拽多个文件批量转换。我实际做过的上位机项目里就有一个功能是把设备日志按天归档成 PDF文件名带日期再加个封面客户反馈一下子正规了不少。最后说一点个人体会技术难点往往不在“生成 PDF”这一步而在编码识别、字体管理、异常隔离这些看起来不起眼的地方。先把这些基础设施做扎实后面加需求就非常顺手。希望这篇总结能帮你少踩几个坑。
返回列表