WinForm添加水印
- 背景
平时维护一个Winform系统,前段时间公司提出所有系统都要加水印,也就有了这个文章内容。我将写出我一路的想法、碰到的问题还有最后的解决方案。
- 网络调研
其实我们团队除了这个Winform客户端,还有一些Web后台,当然他们也是要加水印的。所以我一开始参考web怎么绘制水印,核心代码大致如下:
1 //创建一个画布 2 var can = document.createElement('canvas'); 3 //设置画布的长宽 4 can.width = 120; 5 can.height = 120; 6 7 var cans = can.getContext('2d'); 8 //旋转角度 9 cans.rotate(-25 * Math.PI / 180); 10 cans.font = 'lighter 12px PingFang SC'; 11 12 //设置填充绘画的颜色、渐变或者模式 13 cans.fillStyle = 'rgba(150, 150, 150, 0.3)'; 14 //设置文本内容的当前对齐方式 15 cans.textAlign = 'left'; 16 //设置在绘制文本时使用的当前文本基线 17 cans.textBaseline = 'Middle'; 18 //在画布上绘制填色的文本(输出的文本,开始绘制文本的X坐标位置,开始绘制文本的Y坐标位置) 19 cans.fillText(str, can.width / 8, can.height / 2); 20 21 var div = document.createElement('div'); 22 div.style.pointerEvents = 'none'; 23 div.style.position = 'fixed'; 24 div.style.zIndex = '100000'; 25 div.style.width = document.documentElement.clientWidth + 'px'; 26 div.style.height = document.documentElement.clientHeight + 'px'; 27 div.style.background = 'url(' + can.toDataURL('image/png') + ') left top repeat'; 28 document.body.appendChild(div);
备注很清晰,主要就是用画布绘制了斜向的水印文字,设置透明度,设置事件透传,最后用这个这个画布填充body最外层的div中。
于是,我花了很大的精力来搜索、实现Winform控件的透明和事件透传,如果能实现,我们只需要在主窗体和弹框上,加一个最上层且铺满整个窗体的panel就好。很遗憾,这个我没实现,也没搜到类似的方案。
当然,网上寻找解决方案时,也会很大收获,比如 代码分享:给窗体添加水印 - 陈恩点 - 博客园 (cnblogs.com) ,这篇博客就提供了使用透明无框窗体来覆盖的方式,但是两个窗体并无从属关系,只能把水印窗体设置为topmost,用原窗体的大小来控制水印窗体的大小,导致alt+tab进行程序切换时,水印窗体会展示在整个屏幕的最前面。我试过用WndProc来判断主窗体是否被遮挡,一个是不好做,一个是部分遮挡情况下水印窗体不好处理。放弃此方案。
- 自行绘制
没有捷径可走,也只能撸袖子上了。一开始,我注意到控件绘制时,会调用OnPaint方法,那么我们是不是可以在这个方法结束后,在这个控件上绘制水印呢?而且这个方法是Control类定义的protected方法,是所有控件都会拥有的方法。
1 class XYPanel : System.Windows.Forms.Panel 2 { 3 protected override void OnPaint(System.Windows.Forms.PaintEventArgs e) 4 { 5 base.OnPaint(e); 6 7 var g = e.Graphics; 8 g.SmoothingMode = SmoothingMode.AntiAlias; 9 g.TextRenderingHint = System.Drawing.Text.TextRenderingHint.AntiAlias; 10 //平移画布到需要画印章的位置 11 g.TranslateTransform(0, 75); 12 //逆时针旋转30度 13 g.RotateTransform(-30); 14 for (int x = 5; x < e.ClipRectangle.Right + 75; x += 150) 15 { 16 for (int y = 5; y < e.ClipRectangle.Bottom + 75; y += 150) 17 { 18 //画上文字 19 g.DrawString("Watermark", new Font("微软雅黑", 16, FontStyle.Regular), new SolidBrush(Color.FromArgb(50, 100, 100, 100)), x, y); 20 } 21 } 22 } 23 }
base.OnPaint(e);这句,确保了控件本身的绘制已经完成,我们再执行水印的绘制。使用这个方法的另一个优势是,我们不需要知道何时会触发水印绘制(拖动?尺寸变更?),反正只要触发了控制绘制,自然就会重新绘制水印。可以看一下效果图:
- 坐标系的旋转
上面的截图是有问题的,我们一眼就能看到窗体的左下角和右下角是没有水印的。至于原因,我们想想就能猜到是画布旋转导致的。可以做一个简单的测试:
1 //画上文字 2 //g.DrawString("Watermark", new Font("微软雅黑", 16, FontStyle.Regular), new SolidBrush(Color.FromArgb(50, 100, 100, 100)), x, y); 3 g.FillRectangle(new SolidBrush(Color.FromArgb(50, 100, 100, 100)), 0, 0, this.Width, this.Height);
我们把上面绘制水印文字的部分改成填充整个画布以颜色。可以清晰的看到画布的区域了:
我们来讨论一下怎样让水印布满整个窗口。
首先想到的肯定是不进线画布的旋转,使用水平的水印。那么有没有更好的办法呢?
其实是有的,水印文字绘制的起始位置跟随画布逆时针旋转了30,那么我们通过平面坐标系的旋转公式再进行一次顺时针的旋转就好了。这个公式网络上一搜就能找到,而且会有证明过程。当然,我们使用这个公式的结果就行了,详情请参考 坐标系旋转变换公式图解 - 莫水千流 - 博客园 (cnblogs.com):
x' = x cosA - y sinA
y' = x sinA + y cosA
其中A为从x轴正半轴转向y轴正半轴的角度。那么我水印在窗体中顺时针旋转的角度是 30 度还是 -30 度呢?Winform右为X轴正方向,下为Y轴正方形,所以我们旋转是从X轴正半轴转向Y轴正半轴,所以旋转的角度是 30,所以我们优化一下上面的绘制代码
1 class XYPanel : System.Windows.Forms.Panel 2 { 3 const float cos30 = 0.866f; 4 const float sin30 = 0.5f; 5 protected override void OnPaint(System.Windows.Forms.PaintEventArgs e) 6 { 7 base.OnPaint(e); 8 9 var g = e.Graphics; 10 g.SmoothingMode = SmoothingMode.AntiAlias; 11 g.TextRenderingHint = System.Drawing.Text.TextRenderingHint.AntiAlias; 12 //平移画布到需要画印章的位置 13 g.TranslateTransform(0, 75); 14 //逆时针旋转30度 15 g.RotateTransform(-30); 16 // 绘制画布区域 17 g.FillRectangle(new SolidBrush(Color.FromArgb(50, 100, 100, 100)), 0, 0, this.Width, this.Height); 18 for (int x = 5; x < e.ClipRectangle.Right + 75; x += 150) 19 { 20 for (int y = 5; y < e.ClipRectangle.Bottom + 75; y += 150) 21 { 22 // 计算文字起点位置 23 float x1 = cos30 * x - sin30 * y; 24 float y1 = sin30 * x + cos30 * y; 25 //画上文字 26 g.DrawString("Watermark", new Font("微软雅黑", 16, FontStyle.Regular), new SolidBrush(Color.FromArgb(50, 255, 0, 0)), x1, y1); 27 } 28 } 29 } 30 }
为了方便对比,我绘制了画布区域,同时调整了水印的颜色:
可以看到,水印和窗体展示区域已经完全一致了。
- 多控件的契合
首先考虑一个问题,OnPaint方法是受保护的,那么我们一个Winform程序使用了几十个控件,是不是都要写一个继承类并实现OnPaint呢?这显然不是一个好办法。好在我发现了一个事件Paint,这也是在控件绘制时触发的。这样,我们就不再需要继承控件类,重写OnPaint方法。只需要为需要绘制水印的控件绑定Paint事件!
比如我们要实现上面的效果,只需要为原生的Panel来绑定Paint事件:
1 public partial class FormWaterMark : Form 2 { 3 public FormWaterMark() 4 { 5 InitializeComponent(); 6 7 Panel panel1 = new Panel(); 8 panel1.Dock = System.Windows.Forms.DockStyle.Fill; 9 panel1.Paint += xyPanel1_Paint; 10 this.Controls.Add(panel1); 11 } 12 13 void xyPanel1_Paint(object sender, PaintEventArgs e) 14 { 15 const float cos30 = 0.866f; 16 const float sin30 = 0.5f; 17 18 var g = e.Graphics; 19 g.SmoothingMode = SmoothingMode.AntiAlias; 20 g.TextRenderingHint = System.Drawing.Text.TextRenderingHint.AntiAlias; 21 //平移画布到需要画印章的位置 22 g.TranslateTransform(0, 75); 23 //逆时针旋转30度 24 g.RotateTransform(-30); 25 // 绘制画布区域 26 g.FillRectangle(new SolidBrush(Color.FromArgb(50, 100, 100, 100)), 0, 0, this.Width, this.Height); 27 for (int x = 5; x < e.ClipRectangle.Right + 75; x += 150) 28 { 29 for (int y = 5; y < e.ClipRectangle.Bottom + 75; y += 150) 30 { 31 // 计算文字起点位置 32 float x1 = cos30 * x - sin30 * y; 33 float y1 = sin30 * x + cos30 * y; 34 //画上文字 35 g.DrawString("Watermark", new Font("微软雅黑", 16, FontStyle.Regular), new SolidBrush(Color.FromArgb(50, 255, 0, 0)), x1, y1); 36 } 37 } 38 } 39 }
到现在为止,我们还只完成了单个控件的水印绘制。一个窗体显然不止一个控件,如果每个控件都是这么绘制的,那么最终是什么效果呢?
可以看到,展示效果非常凌乱,不成整体。有什么办法让这么多画布契合起来吗?可以的。我们只要控制画布的原点一致,那么他们最终绘制效果也会是契合的。所以,我把所有控件的画布原点都移到窗体原点,然后配置绘制区域足够包含本控件,就能得到整体一致的水印了:
1 public partial class FormWaterMark : Form 2 { 3 public FormWaterMark() 4 { 5 InitializeComponent(); 6 7 this.panel1.Paint += xyPanel_Paint; 8 this.panel2.Paint += xyPanel_Paint; 9 this.panel3.Paint += xyPanel_Paint; 10 this.panel4.Paint += xyPanel_Paint; 11 } 12 13 void xyPanel_Paint(object sender, PaintEventArgs e) 14 { 15 const float cos30 = 0.866f; 16 const float sin30 = 0.5f; 17 18 var g = e.Graphics; 19 g.SmoothingMode = SmoothingMode.AntiAlias; 20 g.TextRenderingHint = System.Drawing.Text.TextRenderingHint.AntiAlias; 21 22 System.Windows.Forms.Control paintCtrl = sender as System.Windows.Forms.Control; 23 // 计算控件位置 24 int offsetX = 0; 25 int offsetY = 0; 26 while (paintCtrl.Parent != null) 27 { 28 offsetX += paintCtrl.Location.X; 29 offsetY += paintCtrl.Location.Y; 30 paintCtrl = paintCtrl.Parent; 31 } 32 33 // 平移画布到窗体左上角 34 g.TranslateTransform(0 - offsetX, 0 - offsetY + 32); 35 36 //平移画布到需要画印章的位置 37 g.TranslateTransform(0, 75); 38 //逆时针旋转30度 39 g.RotateTransform(-30); 40 for (int x = 0; x < e.ClipRectangle.Right + 64 + offsetX; x += 128) 41 { 42 for (int y = 0; y < e.ClipRectangle.Bottom + 64 + offsetY; y += 128) 43 { 44 // 计算文字起点位置 45 float x1 = cos30 * x - sin30 * y; 46 float y1 = sin30 * x + cos30 * y; 47 //画上文字 48 g.DrawString("Watermark", new Font("微软雅黑", 16, FontStyle.Regular), new SolidBrush(Color.FromArgb(50, 255, 0, 0)), x1, y1); 49 } 50 } 51 } 52 }
对比之前的代码,我们多了一个计算控件偏移量的逻辑,绘制区域也不再是控件大小,而是加上偏移量之后的,展示效果:
可以看到几个控件的画布是完全契合的了。为Winform添加水印的功能也就基本完成了。
- 一些优化
上面的最终代码还有可优化的空间吗?有的,我们为什么要为每个控件绑定Paint事件呢,要知道一个界面拥有50个控件是非常普遍的。而且以后我们每次新增一个控件,都要为它绑定事件,不利于后续的开发。
所以我做了一个扩展方法,自动为窗口及其子控件绑定Paint事件,代码如下:
1 public static class ControlExtension 2 { 3 const float cos30 = 0.866f; 4 const float sin30 = 0.5f; 5 public static void BindWaterMark(this Control ctrl) 6 { 7 if (ctrl == null || ctrl.IsDisposed) 8 return; 9 // 绘制水印 10 if (ctrl.HaveEventHandler("Paint", "BindWaterMark")) 11 return; 12 ctrl.Paint += (sender, e) => 13 { 14 System.Windows.Forms.Control paintCtrl = sender as System.Windows.Forms.Control; 15 var g = e.Graphics; 16 g.SmoothingMode = SmoothingMode.AntiAlias; 17 g.TextRenderingHint = System.Drawing.Text.TextRenderingHint.AntiAlias; 18 19 // 计算控件位置 20 int offsetX = 0; 21 int offsetY = 0; 22 while (paintCtrl.Parent != null) 23 { 24 offsetX += paintCtrl.Location.X; 25 offsetY += paintCtrl.Location.Y; 26 paintCtrl = paintCtrl.Parent; 27 } 28 29 // 平移画布到窗体左上角 30 g.TranslateTransform(0 - offsetX, 0 - offsetY + 32); 31 32 //逆时针旋转30度 33 g.RotateTransform(-30); 34 35 for (int x = 0; x < e.ClipRectangle.Right + 64 + offsetX; x += 128) 36 { 37 for (int y = 0; y < e.ClipRectangle.Bottom + 64 + offsetY; y += 128) 38 { 39 // 计算文字起点位置 40 float x1 = cos30 * x - sin30 * y; 41 float y1 = sin30 * x + cos30 * y; 42 43 //画上文字 44 g.DrawString("Watermark", new Font("微软雅黑", 16, FontStyle.Regular), 45 new SolidBrush(Color.FromArgb(50, 100, 100, 100)), x1, y1); 46 } 47 } 48 }; 49 // 子控件绑定绘制事件 50 foreach (System.Windows.Forms.Control child in ctrl.Controls) 51 BindWaterMark(child); 52 } 53 54 public static bool HaveEventHandler(this Control control, string eventName, string methodName) 55 { 56 //获取Control类定义的所有事件的信息 57 PropertyInfo pi = (control.GetType()).GetProperty("Events", BindingFlags.Instance | BindingFlags.NonPublic); 58 //获取Control对象control的事件处理程序列表 59 EventHandlerList ehl = (EventHandlerList)pi.GetValue(control, null); 60 61 //获取Control类 eventName 事件的字段信息 62 FieldInfo fieldInfo = (typeof(Control)).GetField(string.Format("Event{0}", eventName), BindingFlags.Static | BindingFlags.NonPublic); 63 //用获取的 eventName 事件的字段信息,去匹配 control 对象的事件处理程序列表,获取control对象 eventName 事件的委托对象 64 //事件使用委托定义的,C#中的委托时多播委托,可以绑定多个事件处理程序,当事件发生时,这些事件处理程序被依次执行 65 //因此Delegate对象,有一个GetInvocationList方法,用来获取这个委托已经绑定的所有事件处理程序 66 Delegate d = ehl[fieldInfo.GetValue(null)]; 67 68 if (d == null) 69 return false; 70 71 foreach (Delegate del in d.GetInvocationList()) 72 { 73 string anonymous = string.Format("<{0}>", methodName); 74 //判断一下某个事件处理程序是否已经被绑定到 eventName 事件上 75 if (del.Method.Name == methodName || del.Method.Name.StartsWith(anonymous)) 76 { 77 return true; 78 } 79 } 80 81 return false; 82 } 83 }
这样的话,一个Winfrom绘制水印的功能以不到100行代码的方式完成了。调用也非常方便,在主窗体上 this.BindWaterMark()就好。
- 总结
网络社区完全找不到解决方案,不知道从和下手,难免会畏难。但到真正做出来,发现代码挺少的,也没用什么高级的技术,终究还是要试一试才知道自己行不行。
还是有很多需要完善的,但又有点提不起精力了。比如:
能不能把字体、颜色、透明度配置化?
能不能为某些窗体定制化水印效果?
既然有绑定方法,能不能解绑?
现在不能为动态加载的控件绑定Paint事件,能不能做一个类似于 mainForm.Controls.Changed 的事件来监控动态加载和弹出的窗体?
而且,做的过程中,深感基础的不足,花了多少时间找到OnPaint,又花了多少时间换到Paint,我已经记不得了,但是想来是不少的。最近做JAVA比较多,有疑问都会习惯性的看看源码,但是做C#的时候就不会了,哎,得改。