C#图片文字绘制与水印添加实战:System.Drawing详解

📅 发布时间:2026/9/3 1:37:22
C#图片文字绘制与水印添加实战:System.Drawing详解 简介面向C#开发者的图片文字绘制完整示例集中演示System.Drawing命名空间下Bitmap、Graphics、Font、SolidBrush与DrawString的配合用法覆盖图片加载、画布获取、字体样式设置、文字颜色与位置控制、图像保存等关键环节可应用于图像处理、水印添加、报表生成和设计类工具开发。压缩包共42个文件源码主体包含12个cs文件另有resx资源文件、settings配置、sln解决方案与suo工程状态文件以及可直接运行的exe程序便于对照结果调试整个资源包大小仅67KB轻量易用。已有411人学习浏览适合初学GDI绘图或需要在图片上动态添加文字的C#开发者。资源按照Visual Studio解决方案结构组织主窗体与核心绘制逻辑分离可直接打开调试并可在示例基础上扩展任意字体、大小、颜色、旋转角度或批量绘制需求帮助读者快速掌握图片文字绘制的完整流程与资源释放习惯。1. 项目概述与使用场景1.1 这套源码到底解决什么问题先说说这套C#在图片上绘制文字的源码。搞过WinForms、WPF或者C#上位机开发的朋友多少都会遇到这种需求要把设备编号、采集时间、操作人员姓名、检测结果这些信息直接写到图片上生成带水印或者带标识的图片文件。比如生产线上的视觉检测系统拍完产品照片之后要把OK/NG判定结果、检测日期、产线编号直接印到照片角落方便后续追溯又比如门店的券码核销系统要给电子券图片盖上已核销的红章再比如批量处理产品图要把价格、规格参数批量写到图上。这套源码干的就是这件事儿——用纯C#代码把文字准确、可控地绘制到图片上不需要依赖PhotoShop、画图工具或者任何第三方图像处理软件。源码里面对应的核心逻辑是调用.NET自带的System.Drawing命名空间通过Graphics对象完成绘制最终保存成新图片。它的应用场景覆盖面很广上位机软件、图像采集系统、批量文件处理工具、验证码生成、电子印章、图片批注工具都能直接拿这套思路去用。1.2 适合谁来参考这套代码如果你正在做以下类型的项目这套源码的思路可以直接抄作业C#上位机开发采集到的工业相机照片需要叠加信息。做批量图片处理工具需要把文件名、日期、参数批量写到图片上。做Web后端需要在服务端动态生成带文字的图片。正在学C#的System.Drawing图形编程想找一个完整可运行的实例。这套源码的代码量不大但把加载图片→创建画布→设置字体画刷→绘制文字→保存输出这条完整链路都走通了而且处理好了一些新手容易踩坑的细节比如中文字体兼容、高清大图性能、文字换行、透明背景处理这些。我拿到这套源码之后自己动手跑了一遍又把核心代码拆开做了二次封装接下来把我实际验证过的用法和踩过的坑整理出来。2. 技术选型与底层逻辑拆解2.1 为什么选System.Drawing而不是其他方案在图片上绘制文字C#生态里其实有好几条路可以走System.Drawing、WPF的RenderTargetBitmap、SkiaSharp、ImageSharp还有OpenCV的C#封装。这套源码选的是System.Drawing我觉得这个选择非常合理先说原因。System.Drawing是.NET Framework时代就内置的图像处理命名空间在Windows平台上稳定且无需引入任何外部NuGet包。对于一套要开箱即用的源码来说这是巨大的优势。它封装了GDI图形设备接口底层由Windows系统提供支持处理GIF、JPEG、PNG、BMP这些常见格式的读写都是原生操作。如果你的目标框架是.NET Framework 4.x或者.NET 6/8的Windows平台System.Drawing依然能正常工作。这里有一个很多人容易混淆的点System.Drawing在.NET 6之后被标记为仅支持Windows如果做跨平台部署需要改用ImageSharp或SkiaSharp。但大多数C#上位机、桌面工具、工业视觉软件跑的就是Windows环境用System.Drawing没有任何问题依赖少、内存占用低、代码直观。这套源码选择System.Drawing本质上是在通用性和零依赖之间做出的最优解。2.2 核心绘制链路Graphics对象的工作方式System.Drawing绘图的底层逻辑可以理解为一套画笔画布模型。你要先把图片文件加载成Image对象再从这个Image对象上创建一个Graphics对象Graphics就是那块画布。之后所有绘制操作包括画文字、画线、画矩形、填充颜色都是在这个Graphics对象上完成的。Graphics对象有一些关键属性决定了绘制质量SmoothingMode抗锯齿模式决定文字边缘是否平滑。画文字建议设置成AntiAlias否则放大看会有明显锯齿。TextRenderingHint文字渲染模式。要清晰锐利的文字用ClearTypeGridFit要平滑柔和用AntiAliasGridFit。在深色背景上AntiAliasGridFit效果更好。InterpolationMode图片缩放时的插值方式。如果绘制之前要先缩放原图用HighQualityBicubic能最大程度保留画质。CompositingQuality合成质量影响绘制文字与背景融合的效果默认HighQuality即可。绘制文字的核心方法就是Graphics.DrawString。方法签名是这样的g.DrawString( string s, // 要绘制的文字 Font font, // 字体字体名、字号、样式 Brush brush, // 画刷决定文字颜色、渐变、纹理 RectangleF layoutRectangle, // 绘制区域 StringFormat format // 文字格式对齐、换行、裁剪 );这个方法本身不难难的是怎么把位置排得准、怎么让文字在不同分辨率下不跑偏、怎么处理中英文混排。源码里把这一层逻辑封装好了我把它拆开之后发现核心不外乎三件事计算文字尺寸、确定绘制起点、调用DrawString按坐标绘制。2.3 源码整体结构梳理拿到源码包之后解压出来可以看到关键文件结构大致如下9PM/ ├── FormMain.cs // 主窗体包含界面交互逻辑 ├── ImageTextDrawer.cs // 核心绘制类大多数逻辑集中在这里 ├── Program.cs // 程序入口 ├── pic/ // 测试图片目录 └── bin/ // 编译输出目录我重点阅读的是ImageTextDrawer.cs它把绘制过程封装成了一个可复用的方法输入参数包括原图路径、要写的文字、字体大小、绘制位置、颜色输出是保存后的新图片路径。这种输入-输出清晰的方法封装非常适合移植到自己的项目里。3. 核心代码实现与逐段详解3.1 基础版在图片指定坐标绘制文字这版代码是实现图片绘字的最基础形态逻辑简单直接using System; using System.Drawing; using System.Drawing.Drawing2D; using System.Drawing.Imaging; using System.Drawing.Text; public class ImageTextDrawer { /// summary /// 在图片上绘制文字并保存为新文件 /// /summary /// param namesourcePath原图路径/param /// param nameoutputPath输出图片路径/param /// param nametext要绘制的文字/param /// param namefontSize字号像素/param /// param namex绘制起点X坐标/param /// param namey绘制起点Y坐标/param /// param namecolor文字颜色/param public static void DrawTextOnImage( string sourcePath, string outputPath, string text, float fontSize, float x, float y, Color color) { using (Image image Image.FromFile(sourcePath)) { using (Graphics g Graphics.FromImage(image)) { g.SmoothingMode SmoothingMode.AntiAlias; g.TextRenderingHint TextRenderingHint.AntiAliasGridFit; using (Font font new Font(微软雅黑, fontSize, FontStyle.Bold, GraphicsUnit.Pixel)) using (Brush brush new SolidBrush(color)) { g.DrawString(text, font, brush, x, y); } } // 保存时保留原图格式 ImageFormat format GetImageFormatByExtension(outputPath); image.Save(outputPath, format); } } private static ImageFormat GetImageFormatByExtension(string path) { string ext System.IO.Path.GetExtension(path).ToLower(); switch (ext) { case .png: return ImageFormat.Png; case .bmp: return ImageFormat.Bmp; case .gif: return ImageFormat.Gif; default: return ImageFormat.Jpeg; } } }调用方式也很简单string src D:\test\origin.jpg; string dst D:\test\output.jpg; ImageTextDrawer.DrawTextOnImage(src, dst, 设备编号A-2024-001, 28, 50, 50, Color.Red);这段代码里有两个细节值得注意。第一Font的GraphicsUnit必须显式指定成Pixel这样才能保证字号在不同DPI显示器下表现一致。如果省略这个参数默认是Point1/72英寸在高DPI屏幕上字体实际大小会跟预期有明显偏差。第二image.Save之前要先根据扩展名确定输出格式否则以.png路径保存JPEG图片在某些情况下会报GDI通用错误。3.2 进阶功能批量添加水印与自动换行实际业务里要绘制的文字往往不是一句话而是多行文本或带格式的信息块。比如设备信息、检测时间、操作人员、备注说明这些字段要整齐地排在图片上。如果直接拼接成一行超出图片宽度就画出去了。所以要做自动换行处理。源码里有一个换行绘制的思路给我启发很大用Graphics.MeasureString测量文本尺寸然后手动计算换行位置。我做了一个改进版本支持指定最大宽度自动换行public static void DrawMultilineText( Graphics g, string text, Font font, Brush brush, RectangleF rect, StringFormat format) { StringBuilder currentLine new StringBuilder(); string[] words text.Split( ); // 按空格切分英文场景 // 更通用的做法是按字符切分 foreach (char c in text) { string testLine currentLine.ToString() c; SizeF size g.MeasureString(testLine, font); if (size.Width rect.Width currentLine.Length 0) { // 当前行放不下换行 g.DrawString(currentLine.ToString(), font, brush, rect, format); rect.Y size.Height; currentLine.Clear(); } currentLine.Append(c); } if (currentLine.Length 0) { g.DrawString(currentLine.ToString(), font, brush, rect, format); } }中文场景下按字符逐个累加判断宽度实测效果比按词切分更可靠。每次追加一个字符用MeasureString测一次当前行的实际宽度超了就把之前的内容画出去然后Y坐标下移一行继续。这个方法虽然逐字测量效率不算高但对于几十到几百字的文本完全够用而且逻辑直观、不容易出错。3.3 高颜值方案文字带阴影、描边半透明效果系统集成商或者销售演示的时候图片上的文字如果只是干巴巴的纯色视觉效果会比较生硬。尤其在上位机项目中经常需要在摄像头画面上叠加状态文字比如运行中故障告警待机这些如果文字跟背景颜色接近几乎看不清。给文字加阴影或者描边可以明显提升可读性。阴影效果的核心思路是先用半透明黑色画刷在偏移位置把文字画一遍再用正常颜色在原位置画一遍。一前一后两个图层叠起来视觉上就有了阴影public static void DrawTextWithShadow( Graphics g, string text, Font font, Color textColor, Color shadowColor, float x, float y, int offsetX 2, int offsetY 2) { // 绘制阴影层 using (Brush shadowBrush new SolidBrush(shadowColor)) { g.DrawString(text, font, shadowBrush, x offsetX, y offsetY); } // 绘制主文字层 using (Brush textBrush new SolidBrush(textColor)) { g.DrawString(text, font, textBrush, x, y); } }描边效果稍微费点事需要在文字的上下左右四个方向各画一遍再在四个对角线方向可选画一遍最后中间用主色覆盖。实测下来8方向描边效果最好文字边缘出现一圈光晕在复杂背景上依然清晰可辨public static void DrawTextWithOutline( Graphics g, string text, Font font, Color textColor, Color outlineColor, float x, float y) { using (Brush outlineBrush new SolidBrush(outlineColor)) { // 8方向偏移绘制描边 g.DrawString(text, font, outlineBrush, x - 1, y - 1); g.DrawString(text, font, outlineBrush, x, y - 1); g.DrawString(text, font, outlineBrush, x 1, y - 1); g.DrawString(text, font, outlineBrush, x - 1, y); g.DrawString(text, font, outlineBrush, x 1, y); g.DrawString(text, font, outlineBrush, x - 1, y 1); g.DrawString(text, font, outlineBrush, x, y 1); g.DrawString(text, font, outlineBrush, x 1, y 1); } using (Brush textBrush new SolidBrush(textColor)) { g.DrawString(text, font, textBrush, x, y); } }描边偏移量按1像素做比较适合小字号12~20px如果字号比较大建议偏移量跟字号保持比例否则描边看起来会偏细、不够协调。3.4 文字居中与右下角定位的数学计算图片上文字定位最大的坑就是居中和靠右。先说水平居中。很多人的第一反应是把图片宽度除以2作为文字的X坐标画出来的文字却明显偏右。原因很简单DrawString默认的起始坐标是文字左上角你给的X坐标是文字左上角的位置而不是文字中心点的位置。真正的居中做法要先测量文字宽度再把起点往左移半个文字宽SizeF textSize g.MeasureString(text, font); float centerX (image.Width - textSize.Width) / 2f; float centerY (image.Height - textSize.Height) / 2f; g.DrawString(text, font, brush, centerX, centerY);注意这里用的是image.Width - textSize.Width不是image.Width / 2。减掉文字自身宽度之后剩余空间左右各分一半文字才是真正居中。如果你用image.Width / 2文字中心在图片中心偏右约半个文字宽的位置整体视觉效果明显偏右。右下角定位也类似。假设要在图片右下角留出20像素的边距文字起点坐标应该是SizeF textSize g.MeasureString(text, font); float x image.Width - textSize.Width - 20; float y image.Height - textSize.Height - 20;这个的计算逻辑同样是用图片宽度减去文字宽度再减去边距得到文字左上角的位置。思路清楚了不管把文字放在哪个位置都能准确推算。4. 实际运行验证与效果说明4.1 运行环境准备和基础测试我这边验证这套源码用的环境是Windows 11 专业版64位Visual Studio 2022.NET Framework 4.8 .NET 6 两种目标都试过测试图片1920x1080像素的JPG照片一张PNG透明底图片一张拿到源码包之后用Visual Studio直接打开项目文件先编译。如果报缺少System.Drawing引用的错在项目引用里右键添加从程序集中勾选System.Drawing即可。编译通过之后运行程序界面上会显示一个主窗体我猜原始作者是留了简单的交互界面可以选择图片文件、输入文字、设置字号和颜色点击按钮就执行绘制。测试的时候建议先用一张较小的图片比如800x600快速验证效果确认没问题再换成生产环境的大图。4.2 字符编码与字体兼容性踩坑测试过程中我遇到了一个比较典型的问题直接往代码里写中文字符串绘出来的文字在图片上是正常的但如果文字内容是从文本框或者外部文件读取的就可能出现乱码。排查后发现根因多半是文件读取时没有指定正确的编码格式。普通的File.ReadAllText(path)默认按UTF-8读取如果源文件是GB2312/GBK编码中文会变成锟斤拷这类乱码。解决办法是读取时显式指定编码string text File.ReadAllText(filePath, Encoding.GetEncoding(GB2312));或者统一在工程里把文本文件保存为带BOM的UTF-8格式。另外字体选择也很关键。默认字体如果系统里没有GDI会回退到某种系统默认字体中文字形会变得很难看。稳妥的做法是使用Windows全系标配的字体微软雅黑、宋体或黑体。我在代码里优先用微软雅黑这个字体在Win7及以上系统都有而且字形现代、清晰度高做水印和标注效果都不错。4.3 绘制透明背景文字到PNG图片有一个容易被忽略的点如果你要给透明底PNG图片加上文字直接Graphics.FromImage(image)再DrawString是没问题的文字会直接画在透明画布上PNG的alpha通道会保留透明区域。但如果你是用new Bitmap(width, height)创建一个全新的空白图片再绘制默认背景是透明的但保存的时候如果误存成JPEG格式透明区域会变成黑色。这是个高频坑。正确做法是创建画布时指定一个背景色using (Bitmap bmp new Bitmap(600, 300, PixelFormat.Format32bppArgb)) using (Graphics g Graphics.FromImage(bmp)) { g.Clear(Color.White); // 或者任何你需要的背景色 // ... 绘制逻辑 }如果确实需要透明背景用Format32bppArgb像素格式并保存为PNG。注意不要用Clear(Color.Transparent)某些图像处理组件对完全透明的PNG支持并不好显示出来会带黑底。实测最稳的方式是保存成PNG-32然后用预览工具打开确认透明区域正常。5. 项目二次封装与扩展建议5.1 封装成可复用的工具类前面那段ImageTextDrawer只是最基础的骨架。实际项目里直接调静态方法虽然简单如果需要管理和配置各种绘制参数建议封装成一个支持链式调用的配置类。我在源码基础上做了一次重构把绘制参数和绘制动作分离public class TextOverlayOptions { public string Text { get; set; } public string FontName { get; set; } 微软雅黑; public float FontSize { get; set; } 24; public Color TextColor { get; set; } Color.White; public Color ShadowColor { get; set; } Color.FromArgb(128, 0, 0, 0); public bool EnableShadow { get; set; } true; public bool EnableOutline { get; set; } false; public float X { get; set; } public float Y { get; set; } public TextAlign Align { get; set; } TextAlign.TopLeft; // 枚举TopLeft / TopCenter / TopRight / MiddleCenter / BottomLeft / BottomCenter / BottomRight }这个封装的好处是调用侧可以统一管理多个要叠加的文本块例如同时绘制设备编号检测时间判定结果三个信息块只需要准备三个TextOverlayOptions对象循环调用绘制方法就行。代码可读性和可维护性都明显提升。5.2 与上位机系统的集成思路很多正在做C#上位机项目的朋友可能想把同样的功能集成到现有的视觉检测或数据管理系统里面。这里分享一个我实践过的集成方案做一个独立的ImageTextService类在项目启动时初始化一次后续任何窗体需要给图片添加标注只需要调用这一个类的公开方法。public class ImageTextService { private static readonly ImageTextService _instance new ImageTextService(); public static ImageTextService Instance _instance; public string AddOverlayToImage(string sourcePath, string outputDir, ListTextOverlayOptions options) { string fileName Path.GetFileNameWithoutExtension(sourcePath); string ext Path.GetExtension(sourcePath); string outputPath Path.Combine(outputDir, ${fileName}_overlay{ext}); using (Image image Image.FromFile(sourcePath)) using (Graphics g Graphics.FromImage(image)) { g.SmoothingMode SmoothingMode.AntiAlias; g.TextRenderingHint TextRenderingHint.AntiAliasGridFit; foreach (var opt in options) { using (Font font new Font(opt.FontName, opt.FontSize, FontStyle.Bold, GraphicsUnit.Pixel)) { // 根据对齐方式计算实际坐标 float drawX opt.X; float drawY opt.Y; if (opt.Align ! TextAlign.TopLeft) { SizeF textSize g.MeasureString(opt.Text, font); drawX CalculateXByAlign(image.Width, textSize.Width, opt.Align); drawY CalculateYByAlign(image.Height, textSize.Height, opt.Align); } if (opt.EnableShadow) DrawTextWithShadow(g, opt.Text, font, opt.TextColor, opt.ShadowColor, drawX, drawY); else if (opt.EnableOutline) DrawTextWithOutline(g, opt.Text, font, opt.TextColor, Color.Black, drawX, drawY); else using (Brush brush new SolidBrush(opt.TextColor)) g.DrawString(opt.Text, font, brush, drawX, drawY); } } ImageFormat format GetImageFormatByExtension(ext); image.Save(outputPath, format); } return outputPath; } }这套代码我在实际项目里验证过处理4000x3000的工业相机照片叠加3行文字信息从加载到保存大约耗时300~500毫秒。对于非实时的标注需求足够用。如果要做实时视频流叠加建议改用GPU加速的方案或者用Direct2D那是另外一个话题了。5.3 性能优化与大量图片处理的批处理模式如果你要批量处理几千张图片逐张调用Image.FromFile再Save性能会有一定瓶颈。主要原因在于每次Image.FromFile都会完整解码图片Save又会重新编码。优化思路有两个方向第一减少重复创建字体和画刷。把Font和Brush提到循环外面用完之后统一释放。实测同一字体循环绘制200张图能省下约30%的时间。第二对于大图超过5000x5000像素MeasureString和DrawString都比较耗内存。可以考虑先用缩略图计算文字位置再用实际坐标绘制或者用Bitmap.Clone配合LockBits做像素级操作。不过后者的复杂度高不少一般批量场景用优化方向一就够了。如果还要更快可以引入Parallel.For并行处理但要注意Graphics.FromImage生成的Graphics对象不能跨线程共用每个线程必须各自创建自己的Graphics对象。还有图片保存路径之间不能冲突。实测4线程并行处理1000张1920x1080图片耗时大约是单线程的2.6倍左右提升效果明显。6. 常见问题与排查经验总结6.1 报参数无效或GDI通用错误这类错误十有八九出现在Image.FromFile或者image.Save阶段。我整理了一张排查表现象可能原因解决办法Image.FromFile抛参数无效文件路径错误、文件被占用或文件损坏检查文件是否存在用File.Exists提前判断关闭占用文件的进程Save时抛GDI通用错误输出路径目录不存在或保存格式不支持先Directory.CreateDirectory创建目录确认扩展映射的ImageFormat有效图片加载正常但绘制后是空白绘制颜色跟背景色一致或Graphics设置了裁剪区检查SolidBrush的颜色与图片背景是否有足够对比度中文字体变成方框乱码系统缺少对应字体或读取文本编码不正确改用系统自带字体读取文本时指定正确编码6.2 绘制文字位置出现偏移前面提到的文字居中计算是最常见的偏移原因。另外Graphics.MeasureString测量出来的高度跟实际绘制高度有轻微偏差GDI的字体度量机制比较复杂多行文本之间容易出现行距不准确的问题。如果对位置精度要求很高可以考虑改用TextRenderer.MeasureText做测量它是WinForms的文本渲染API跟DrawString的内部度量略有差异但实测在大多数场景下位置更精准。我还踩过一个跟DPI相关的坑在开发机上文字位置正常部署到用户电脑上明显偏了。原因是用户电脑的显示缩放比例不是100%。解决方案是给程序添加.manifest文件声明DPI感知或者在代码里设置SetProcessDPIAware()。如果在上位机项目中遇到类似问题优先检查这个方向。6.3 图片保存后画质下降很多朋友发现绘制后保存的JPEG图片比原图模糊甚至出现色块。原因是Image.Save保存JPEG时默认采用质量压缩。如果对画质有要求需要用EncoderParameters显式设置保存质量ImageCodecInfo jpegCodec GetEncoderInfo(image/jpeg); EncoderParameters parameters new EncoderParameters(1); parameters.Param[0] new EncoderParameter(Encoder.Quality, 95L); // 质量95%范围0-100 image.Save(outputPath, jpegCodec, parameters);GetEncoderInfo通过ImageCodecInfo.GetImageEncoders()遍历按MimeType匹配即可。把质量设到95以上肉眼基本看不出画质损失。如果原图本身就是PNG格式直接保存PNG是无损的不需要这个处理。6.4 大图绘制内存占用过高处理超大图片比如8000x6000的卫星图或扫描图时Image.FromFile加载的位图在内存中占用的空间非常大加上Graphics操作临时分配的内存32位进程容易直接内存溢出。建议排查方向项目平台目标设置为x64避免2GB内存限制。处理完的Image对象用using及时释放不要留着引用。如果只需要在图上叠加文字可以考虑先用Bitmap的低分辨率副本定位文字再用Graphics.DrawImage局部绘制减少一次性全图加载的压力。实在不行按分块方式读取和处理但那种情况已经属于特殊的图像处理架构设计了。7. 实战经验分享与后续扩展这套源码和扩展代码我在三个项目里真正落地过分别是第一个是给一家设备厂商做的上位机检测软件工业相机拍完产品图需要把产品型号生产批次检测结果时间戳写到图片右下角。用的是上面封装好的ImageTextService稳定跑了半年多没出过问题。第二个是门店券码核销的示意图生成工具要批量给电子券图片盖已核销字样的红色印章效果。做了个简单的旋转角度处理让文字稍微倾斜8度模拟真实盖章的视觉效果整体效果客户很满意。第三个是一个内部资料管理的小工具批量把文件路径、备注信息写到预览图上。用的就是满图自动换行的逻辑。最让我头疼的就是中文编码问题最后统一改成UTF-8带BOM保存再读取才彻底解决。关于后续扩展如果你想做得更完善还可以加这些功能绘制二维码或者条形码用ZXing.Net库把二维码和图片叠加。支持自定义水印图片Logo叠加上去在绘制文字之前先DrawImage。把绘制参数保存成配置文件JSON或XML让非开发人员也能通过修改配置调整水印内容。支持从Excel或CSV批量导入要绘制的内容按行生成图片。最后再分享一个经验做这类图像叠加功能一定要尽早把字体、颜色、位置、对齐方式这些参数从硬编码改造成可配置。我第一次就是图省事把坐标写死在代码里结果客户换了张不同尺寸的图片所有坐标全部要重调改起来非常痛苦。参数化配置一次性投入后面能省大量沟通成本。希望这篇拆解对正在做类似需求的朋友有帮助。本文还有配套的精品资源点击获取