C# GDI+ 图片绘制文字实战:批量水印与标注源码解析

发布时间:2026/9/3 18:35:57
C# GDI+ 图片绘制文字实战:批量水印与标注源码解析 简介一份面向C#初中级开发者的图片文字绘制完整示例源码包解决在图像处理、报表生成等场景中需要向图片添加文字的实际需求。资源共42个文件以cs源文件、resx资源文件、sln工程文件为主同时包含可运行的exe、dll、配置文件及演示gif方便直接查看效果或二次开发整体仅67KB轻量易用。资源围绕System.Drawing命名空间展开覆盖Bitmap加载图片、Graphics创建绘图表面、Font与SolidBrush定义文字样式颜色、DrawString绘制文字等关键环节并附坐标系统与异常处理说明。压缩包还包含xlsx、css等辅助文件帮助理解完整项目结构。已有411人学习下载适合希望快速掌握C#图像绘制并直接借鉴完整工程代码的开发者。 做上位机的朋友前两天问我有没有现成的 C# 在图片上绘制文字的实例源码他想给设备抓拍图自动加上当前时间和设备编号。这种需求在桌面端开发里太常见了——批量水印、证书打印、截图标注、票据打码说白了都是在图片上把文字画上去再保存。我翻出之前整理过的完整示例打包成了 9PM.rar这里把核心实现、绘制细节和几个容易踩的坑一次说清楚。这篇文章适合用 C# 做 WinForm / WPF、上位机开发或者需要给图片批量加水印的朋友参考直接照着改就能用。1. 先说清楚这个实例到底做了什么1.1 这些场景全都要在图片上“写字”我整理的这个源码解决的是一个非常具体但又高频的需求给一张现成的图片JPG、PNG、BMP 都行附加指定文字然后输出成新图片。听起来简单但实际场景五花八门设备抓拍图叠加信息上位机采集到摄像头图片后把拍摄时间、设备编号、温度值直接画到图像角落方便追溯。批量商品水印给几百张商品图统一加上店铺名、价格、活动标签省去手动 PS 的重复劳动。证书/奖状/邀请函生成把获奖人姓名、日期动态绘制到模板底图上批量导出带名字的证书。截图标注给操作截图加红色箭头说明或文字描述用于生成教程文档。这些需求的底层逻辑完全一样读入图片 - 选择字体、画刷 - 计算文字位置 - 绘制 - 保存。9PM.rar 里就是围绕这条链路做的一个可编译、可改参数的完整工程不是单个方法片段而是整理好的类库和调用示例。1.2 为什么我选了 GDI 而不是其他图像库做图片绘制文字.NET 生态里常用方案有三个System.DrawingGDI、SkiaSharp、SixLabors.ImageSharp。我最终选择 GDI主要是从 Windows 桌面端场景考虑的。对比项GDISystem.DrawingSkiaSharpImageSharp环境依赖系统自带零额外依赖需要 NuGet 包需要 NuGet 包跨平台仅 WindowsWindows Forms 场景足够支持多平台纯托管支持多平台中文字体支持直接调用系统字体稳定需自行加载字体文件可通过系统字体或文件字体学习成本低微软文档和案例极多中API 风格偏 C中API 现代化性能批量操作足够注意释放资源即可较好一般如果你是做纯 Windows 桌面工具GDI 的优势非常明显不需要引入额外 DLL不需要处理跨平台兼容直接在 WinForm 项目里引用 System.Drawing 就能跑。SkiaSharp 和 ImageSharp 更适合跨平台服务端或者对现代渲染有要求的场景比如要复杂的渐变、滤镜、文字描边等。我们这次的核心需求只是“在图片上写文字”GDI 的 DrawString 已经覆盖得非常好没必要杀鸡用牛刀。2. 绘制文字之前这几个细节必须先搞懂2.1 画布从哪来Graphics 对象不是 new 出来的很多新手第一次接触时容易踩坑以为 Graphics 可以直接 new然后往上面画。实际上不是这样。Graphics 对象必须通过某个具体“画布”获取给图片绘制文字时正确写法是Graphics.FromImage(bitmap)传进去一个 Bitmap 对象之后所有绘制操作都会作用于这个 Bitmap。还有一点要特别注意Graphics.FromImage要求传入的 Bitmap 不能是索引像素格式比如某些 GIF 或 8 位 PNG否则会抛异常。实际开发中稳妥的做法是如果源图片格式特殊先转换成 32 位 ARGB 格式再操作。另外Graphics 对象是非托管资源的封装强占 GDI 句柄务必要用using或者手动Dispose()释放否则程序跑久了会 GDI 泄漏。using (Bitmap image new Bitmap(sourcePath)) { using (Graphics g Graphics.FromImage(image)) { // 在这里执行绘制 } // 到这里 g 已释放image 还可以继续使用 }2.2 字号单位是 Point 不是像素这个坑最隐蔽我见过很多人在 DrawString 时直接写new Font(微软雅黑, 36)心里想的是“我要 36 像素大的字”但实际上 Font 的默认单位是 Point磅不是像素。在 96 DPI 的屏幕上1 磅约等于 1.333 像素所以 36 磅实际渲染出来是 48 像素左右。如果你还做了图片缩放最终效果会跟预期差得更远。解决办法有两个一是换算像素值 磅值 * DPI / 72二是在构造 Font 时显式指定单位为像素using (Font font new Font(Microsoft YaHei, 36, FontStyle.Bold, GraphicsUnit.Pixel)) { // 这里的 36 就是 36 像素行为更直观 }我习惯用GraphicsUnit.Pixel因为定位和边距都按像素算逻辑统一不会有突然变大的意外。2.3 透明度和画刷水印效果好不好看全看这里绘制文字需要用到字体和画刷。画刷常见的是SolidBrush但很多人不知道可以通过Color.FromArgb控制透明度。给图片加半透明水印时这就是核心// 第一个参数是 Alpha 透明度0 完全透明255 完全不透明 using (Brush brush new SolidBrush(Color.FromArgb(128, Color.White))) { // 半透明白色覆盖在不规则背景上依然清晰 }如果你要更高级的效果比如渐变文字可以换成LinearGradientBrush。但要注意LinearGradientBrush 的渐变范围基于指定的矩形坐标不是自动跟随文字边界所以需要先测量文本大小再按文字区域构造画刷。这个细节不少人忽略结果画出来的字只有半边渐变。2.4 中文字体的选择直接决定输出质量绘制中文字幕或水印字体名称建议写英文名比如Microsoft YaHei微软雅黑、SimSun宋体。原因很简单不同语言环境的系统对中文字体名的解析兼容性不同用英文名更可靠。在 Windows 上微软雅黑渲染清晰适合水印和标注如果是证书、公文类场景宋体更庄重。字体大小设计上建议先测量文字尺寸再决定是否需要换行或缩小。3. 完整源码实现从单张绘制到批量处理3.1 最核心的方法给一张图片绘制文字下面这个方法完整封装了“加载图片 - 绘制文字 - 按格式保存”全流程参数都做了可配置化直接用就行。using System; using System.Drawing; using System.Drawing.Drawing2D; using System.Drawing.Imaging; using System.Drawing.Text; using System.IO; public class ImageTextDrawer { /// summary /// 在图片上绘制文字 /// /summary /// param namesourcePath源图片路径/param /// param nameoutputPath输出图片路径/param /// param nametext要绘制的文字/param /// param namefontSize字号像素/param /// param namefontFamily字体/param /// param namehexColor文字颜色如 #FFFFFF/param /// param nameposition九宫格位置1左上 2上中 3右上 4左中 5居中 6右中 7左下 8下中 9右下/param /// param namemargin边距像素/param /// param namealpha透明度 0~255/param /// param namebold是否加粗/param public static void DrawTextOnImage( string sourcePath, string outputPath, string text, int fontSize 36, string fontFamily Microsoft YaHei, string hexColor #FFFFFF, int position 9, int margin 30, int alpha 255, bool bold true) { using (Bitmap image LoadBitmapSafe(sourcePath)) { using (Graphics g Graphics.FromImage(image)) { // 抗锯齿设置保证文字边缘平滑 g.SmoothingMode SmoothingMode.AntiAlias; g.TextRenderingHint TextRenderingHint.AntiAliasGridFit; using (Font font new Font(fontFamily, fontSize, bold ? FontStyle.Bold : FontStyle.Regular, GraphicsUnit.Pixel)) using (Brush brush new SolidBrush(Color.FromArgb(alpha, ColorTranslator.FromHtml(hexColor)))) { // 测量文字实际渲染尺寸 SizeF textSize g.MeasureString(text, font); // 根据九宫格位置计算文字左上角坐标 float x margin; float y margin; if (position 2 || position 5 || position 8) x (image.Width - textSize.Width) / 2f; else if (position 3 || position 6 || position 9) x image.Width - textSize.Width - margin; if (position 4 || position 5 || position 6) y (image.Height - textSize.Height) / 2f; else if (position 7 || position 8 || position 9) y image.Height - textSize.Height - margin; // 真正绘制文字 g.DrawString(text, font, brush, x, y); } } // 保存输出文件 SaveImage(image, outputPath, 90L); } } }这里有几个关键点SmoothingMode.AntiAlias和TextRenderingHint.AntiAliasGridFit同时设置后文字边缘会很平滑尤其在高分屏上差异明显。MeasureString用来测量文字的宽度高度这样文字在九宫格定位时才能做到居中或靠右对齐而不是基于初始点粗略摆放。3.2 图片加载避免源文件长时间被锁定用new Bitmap(sourcePath)直接加载有个隐性副作用文件句柄会被 Bitmap 对象占用除非释放 Bitmap否则文件无法被其他进程修改或删除。在批处理场景里如果你处理完还要移动或删除源文件就会遇到“文件正在被占用”的错误。我的经验是先读取字节流再通过 MemoryStream 创建 Bitmapprivate static Bitmap LoadBitmapSafe(string sourcePath) { byte[] bytes File.ReadAllBytes(sourcePath); using (MemoryStream ms new MemoryStream(bytes)) { return new Bitmap(ms); } }这样加载后的 Bitmap 跟源文件完全解耦保存后源文件可以自由处理。代价是内存多一些但图片普遍在几十 MB 以内完全能接受。3.3 保存格式和质量JPEG 压到多少不糊保存图片时直接image.Save(path)会使用默认编码器JPEG 质量大约是 75够用但不适合对清晰度要求高的场景。更好的方式是显式指定 JPEG 编码质量和输出格式。private static void SaveImage(Image image, string outputPath, long quality) { string ext Path.GetExtension(outputPath).ToLowerInvariant(); if (ext .png) { image.Save(outputPath, ImageFormat.Png); return; } if (ext .bmp) { image.Save(outputPath, ImageFormat.Bmp); return; } // 默认按 JPEG 处理quality 可设置 0~100 ImageCodecInfo jpegCodec GetEncoderInfo(image/jpeg); EncoderParameters parameters new EncoderParameters(1); parameters.Param[0] new EncoderParameter(Encoder.Quality, quality); image.Save(outputPath, jpegCodec, parameters); } private static ImageCodecInfo GetEncoderInfo(string mimeType) { foreach (ImageCodecInfo codec in ImageCodecInfo.GetImageEncoders()) { if (codec.MimeType mimeType) return codec; } return null; }质量参数建议产品图、证件类用 90~95体积稍大但细节保留好水印预览图用 80~85 足够。低于 75 的时候文字边缘容易出蚊状噪点不推荐。3.4 批量处理一个文件夹的图片全部加上文字单张绘制搞定后批量处理就是加一层文件遍历而已。我给的示例类库里包含BatchDraw方法遍历指定目录下所有常见图片格式统一调用上面的绘制方法。public static void BatchDraw( string inputDirectory, string outputDirectory, string text, int fontSize 36, int position 9, int margin 30, Actionstring progressCallback null) { if (!Directory.Exists(inputDirectory)) throw new DirectoryNotFoundException($输入目录不存在{inputDirectory}); if (!Directory.Exists(outputDirectory)) Directory.CreateDirectory(outputDirectory); string[] extensions { *.jpg, *.jpeg, *.png, *.bmp }; foreach (string pattern in extensions) { foreach (string file in Directory.GetFiles(inputDirectory, pattern)) { string fileName Path.GetFileName(file); string outputFile Path.Combine(outputDirectory, fileName); try { DrawTextOnImage( sourcePath: file, outputPath: outputFile, text: text, fontSize: fontSize, position: position, margin: margin); progressCallback?.Invoke($已处理{fileName}); // 及时释放内存避免大量图片堆积导致内存暴涨 GC.Collect(); } catch (Exception ex) { progressCallback?.Invoke($处理失败{fileName}原因{ex.Message}); } } } }批处理时我特意在每张图片处理完后调用了GC.Collect()虽然很多人不建议频繁调用但在这个场景下大批量图片处理时 Bitmap 对象会占用大量非托管内存及时回收能显著降低内存峰值。如果追求性能可以不调用而改为设计模式上的复用一个 Bitmap 实例但复杂度会上来日常中小规模批处理用这个写法最简单稳妥。4. 我在实际使用中踩过的坑与排查记录4.1 中文绘出来全是方块/问号这是最高频的问题。原因基本都是字体不支持中文或者字体名称写错导致系统回退到了默认字体。GDI 的默认字体可能是英文相关字体遇到中文就渲染成方块。排查思路先确认fontFamily传入的是系统真实存在的字体名称比如Microsoft YaHei而不是微软雅黑这样的本地化名称。在某些精简版 Windows 系统上还需要检查字体是否真的安装了。更稳妥的做法是使用以下代码列出系统所有可用字体挑选后传入foreach (FontFamily family in FontFamily.Families) { Console.WriteLine(family.Name); }如果你的应用有自定义字体文件比如特殊设计字体可以先PrivateFontCollection.AddFontFile()加载再从集合里取出 FontFamily 使用这样不依赖目标机器是否安装该字体。4.2 绘制后图片被锁定文件删不掉、改不了这个我在前面其实已经铺垫了。直接new Bitmap(filePath)会导致 Bitmap 和文件句柄绑定哪怕位图已经用完但没有 Dispose文件依然处于占用状态。批量处理时一旦源文件被占用后续重命名、移动、删除文件等操作全部报错。推荐做法就是上面写的LoadBitmapSafe方法先File.ReadAllBytes再通过MemoryStream创建 Bitmap彻底和源文件解绑。如果你对内存占用非常敏感可以在读取文件后立刻用FileStream和BinaryReader读字节效果相同。4.3 文字位置不准确特别是居中时偏右偏下DrawString绘制字符串时坐标点是文字左上角的起点而不是文字的中心。直接拿图片宽高除以 2 当作文字的 x、y 坐标文字必然偏到右下角。正确的居中方式是先用MeasureString测量出文字区域大小然后用(图片宽 - 文字宽) / 2计算起点。另外一个容易被忽略的点Graphics.MeasureString在不同 DPI 下测得的值可能会变化。如果你的程序运行在不同缩放比例的机器上建议在构造 Graphics 后先通过g.DpiX、g.DpiY确认分辨率再决定是否需要做缩放。高分屏上如果不处理文字可能出现“看着挺大但位置却和预期不一致”的情况。4.4 程序跑久了内存暴涨或者 GDI 对象耗尽图片处理类程序最常见的性能问题就是资源泄漏。Bitmap、Graphics、Font、Brush 这些对象都持有非托管资源光靠垃圾回收器不会立刻释放它们。用任务管理器 - 详细信息 - 添加列 - GDI 对象可以看到进程的 GDI 句柄数如果这个数字持续增长且不回落基本就是某个地方忘记 Dispose 了。我写这套代码时的原则很简单凡是实现了 IDisposable 的对象一律用 using 包裹。包括 Bitmap、Graphics、Font、Brush、Pen、MemoryStream、EncoderParameters。这个习惯养成后GDI 泄漏问题基本不会再出现。4.5 批量处理时太慢怎么优化如果图片数量非常大比如上千张单线程循环处理速度确实捉急。优化思路有两个方向用Parallel.ForEach做多线程并行每个线程处理不同的图片注意绘制方法内部不要有共享的可变状态当前这个类设计天然线程安全可以直接并行。减少重复创建对象字体、画刷如果参数一致可以提到循环外面创建但要小心不能多个线程共用同一个 Graphics 或 Bitmap 实例。并行处理的代码大致这样var files Directory.GetFiles(inputDirectory, *.jpg, SearchOption.TopDirectoryOnly); Parallel.ForEach(files, new ParallelOptions { MaxDegreeOfParallelism Environment.ProcessorCount }, file { DrawTextOnImage(file, Path.Combine(outputDirectory, Path.GetFileName(file)), text); });这里有个隐含前提CPU 核数决定了并行度上限图片加载和保存本身有 IO 操作并行度太高反而会因为磁盘争抢变慢。实际测试下来4~8 路并行通常是最优区间。最后分享一点个人体会这套图片绘制文字的代码我在几个不同的项目里反复改过。最早是做设备抓拍图时间叠加后来被同事要去改成批量水印工具再后来还有人拿它生成培训证书。改来改去核心的 DrawString 调用一直没有变过变的全是外围的参数化、批量化和异常处理。如果你打算在自己的项目里直接用我建议先拿一张测试图把参数跑通尤其是字体大小、透明度、位置模式这三个值在不同尺寸的图片上效果差异非常大。另外如果需求是要在界面上实时预览效果就把绘制逻辑封装成“输入参数返回新图片”的纯方法UI 层只需要拿到结果后刷新 PictureBox 就行这种松耦合设计以后改起来会特别顺手。本文还有配套的精品资源点击获取