简介:面向WPF机器视觉开发者的C#图像控件与ROI管理示例,聚焦仿Halcon HSmartWindowControl的交互体验,解决图像加载显示、感兴趣区域绘制与增删改管理等常见需求,适合具备一定C#与WPF基础、希望理解视觉界面控件设计的开发者参考。压缩包共86个文件,约319KB,以31个cs源码为核心,辅以xaml界面、csproj与sln解决方案、dll依赖及配置资源,整体体量轻便,便于直接打开工程梳理结构。已有412人学习下载,说明该主题在图像处理与工业视觉界面开发中具有一定关注度。内容围绕自定义图像控件、ROI数据结构与交互事件展开,可从中了解图像与ROI的模块化组织、鼠标操作响应、图形绘制以及结合OpenCV进行扩展的思路,也能借鉴项目分层和接口设计方式,为后续实现模板匹配、自动检测等功能提供可复用的工程骨架。
1. 从一块「不听话」的显示区说起:这套 C# WPF 图像控件到底解决了什么
在 WPF 里做视觉上位机,图像显示和 ROI 交互往往是第一个卡住进度的模块:商业控件 HSmartWindowControl 的交互手感确实好,但授权、部署、定制都受制;自己用 OpenCV 读图,再写一个 C# 自定义图像控件,把 ROI 的绘制与管理做扎实,反而更可控。这套实现面向的就是这类场景:用 WPF 原生绘制承载图像,用 OpenCV 做像素进出,把矩形、圆、多边形、线段的 ROI 交互和集合管理收在一个控件里,仿的是 Halcon HSmartWindowControl 那套「滚轮缩放、拖拽平移、句柄编辑」的手感。它适合正在做工业视觉、医疗影像、标注工具上位机的 WPF 开发,尤其是那种不想被商业控件绑死、又需要自己扩展 ROI 类型的项目。读下去你会看到图像层、ROI 层、交互层怎么拆,鼠标状态机怎么落,以及缩放后 ROI 偏移、图像发黑、拖拽卡顿这些坑怎么排。
2. 架构拆解:图像层、ROI 层、交互层怎么分,才不至于糊成一坨
2.1 目录结构与核心类职责
我一般会把图像显示和 ROI 交互拆成两个FrameworkElement,而不是把所有逻辑塞进一个自定义控件。图像层只负责BitmapSource的绘制、缩放、平移;ROI 层只负责把图像坐标映射到控件坐标并画出来;鼠标状态机放在 ROI 层,RoiManager只管理集合,不碰 WPF 渲染。这样拆的好处是,ROI 层可以单独设IsHitTestVisible=false做只读预览,也可以叠多个层做不同颜色的标注,OpenCV 的Mat也不会顺着 UI 引用到处跑。
| 路径 | 职责 | 关键点 |
|---|---|---|
Controls/ImageCanvas.cs | 图像绘制、缩放、平移 | 重写OnRender,维护Scale、Offset |
Controls/RoiOverlay.cs | ROI 绘制、命中测试、鼠标状态机 | 只存图像坐标,渲染时统一映射 |
Models/RoiBase.cs | ROI 抽象基类 | Draw、HitTest、Move、Resize、GetHandles、ToDto |
Models/RectRoi.cs等 | 具体 ROI 类型 | 矩形、圆、多边形、线段各一个文件 |
Services/RoiManager.cs | ROI 集合与选中态 | 增删、倒序命中测试、选中切换 |
Services/RoiSerializer.cs | ROI 序列化与反序列化 | DTO + 类型工厂,留版本字段 |
Interop/MatBitmapConverter.cs | OpenCVMat转BitmapSource | 处理 stride、像素格式、Freeze |
这张表不是摆设,后面改代码基本就按这张表找位置。比如新增一个「旋转矩形」ROI,模型层加一个类,工具枚举加一个值,反序列化工厂加一个分支,渲染和命中测试都在新类里闭环,不需要回头改ImageCanvas。图像层和 ROI 层之间只通过一个ICoordinateMapper接口通信,接口里就三个成员:Scale、ImageToControl、ControlToImage。这样单元测试可以拿一个假的 mapper 跑,不用起 WPF 窗口。
2.2 渲染管线:从 Mat 到 WriteableBitmap,再到 OnRender
OpenCV 读进来的图是Mat,WPF 要显示得先转成BitmapSource。这里最常见的翻车点是 stride 和像素格式:Mat的每一行可能有对齐填充,Step()不一定等于Width * Channels,直接拿Width * Channels当 stride,图像就会斜着花。另一个坑是通道顺序,OpenCV 默认Bgr24,WPF 的PixelFormats.Bgr24正好对得上,不用先转 RGB。下面这段转换代码是我在项目里反复用的版本:
public static BitmapSource ToBitmapSource(Mat mat) { if (mat == null || mat.Empty()) return null; PixelFormat format; if (mat.Channels() == 1) format = PixelFormats.Gray8; else if (mat.Channels() == 3) format = PixelFormats.Bgr24; else if (mat.Channels() == 4) format = PixelFormats.Bgra32; else throw new NotSupportedException($"不支持的通道数:{mat.Channels()}"); // Mat.Step() 是每一行的字节数,包含 OpenCV 的对齐填充 int stride = (int)mat.Step(); int bufferSize = stride * mat.Height; // dpi 固定 96,让 BitmapSource.Width 与 PixelWidth 一致,坐标换算少一层坑 var bitmap = BitmapSource.Create( mat.Width, mat.Height, 96, 96, format, null, mat.Data, bufferSize, stride); // Freeze 之后可以跨线程使用,UI 线程只读不写 bitmap.Freeze(); return bitmap; }这段代码的逻辑很直:先按通道数选PixelFormat,单通道走Gray8,三通道走Bgr24,四通道走Bgra32;stride必须用mat.Step(),不能自己算;bufferSize是stride * height;BitmapSource.Create会把Mat.Data指向的内存复制一份,所以调用完就可以释放Mat,不会出现「图显示到一半内存被回收」的黑匣子问题。参数里dpiX、dpiY固定 96,是为了让后续ImageSource.Width等于像素宽度,缩放计算不用再除 DPI。如果你拿到的Mat是 ROI 子矩阵,Step()仍然可能是原图的行宽,这段代码也能正确处理,因为 stride 传的是真实行字节数。
图像层拿到BitmapSource后,在OnRender里按Offset和Scale画一个矩形区域:
protected override void OnRender(DrawingContext dc) { dc.DrawRectangle(Brushes.Black, null, new Rect(RenderSize)); if (ImageSource == null) return; var destRect = new Rect( Offset.X, Offset.Y, ImageSource.Width * Scale, ImageSource.Height * Scale); RenderOptions.SetBitmapScalingMode(this, BitmapScalingMode.HighQuality); dc.DrawImage(ImageSource, destRect); }destRect的左上角就是Offset,宽高是图像 DIP 尺寸乘Scale。BitmapScalingMode.HighQuality在缩小时会做插值,看起来更顺眼,但大图频繁缩放时会吃性能;如果项目里更在意拖拽帧率,可以切成LowQuality或NearestNeighbor,这个后面性能一节还会提。OnRender里不要做任何耗时的图像处理,Mat转换、滤波、二值化都应该在后台线程做完,UI 线程只负责把现成的BitmapSource画上去。
2.3 依赖属性与控件契约:ImageSource、Scale、Offset、ROI 集合
自定义控件要给外部绑定的东西,最好走依赖属性,别用普通 CLR 属性。ImageSource变了要触发重绘,Scale和Offset变了也要触发重绘,这些用FrameworkPropertyMetadataOptions.AffectsRender就能自动搞定。下面这三个属性是图像层的核心契约:
public static readonly DependencyProperty ImageSourceProperty = DependencyProperty.Register( nameof(ImageSource), typeof(ImageSource), typeof(ImageCanvas), new FrameworkPropertyMetadata( null, FrameworkPropertyMetadataOptions.AffectsRender, OnImageSourceChanged)); public static readonly DependencyProperty ScaleProperty = DependencyProperty.Register( nameof(Scale), typeof(double), typeof(ImageCanvas), new FrameworkPropertyMetadata( 1.0, FrameworkPropertyMetadataOptions.AffectsRender, OnViewChanged, CoerceScale)); public static readonly DependencyProperty OffsetProperty = DependencyProperty.Register( nameof(Offset), typeof(Point), typeof(ImageCanvas), new FrameworkPropertyMetadata( new Point(0, 0), FrameworkPropertyMetadataOptions.AffectsRender, OnViewChanged)); private static object CoerceScale(DependencyObject d, object baseValue) { var value = (double)baseValue; return Math.Clamp(value, 0.05, 64.0); } private static void OnImageSourceChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) { var canvas = (ImageCanvas)d; canvas.Scale = 1.0; canvas.Offset = new Point(0, 0); }AffectsRender的意思是属性变化后 WPF 自动调InvalidateVisual,不用手写刷新。CoerceScale把缩放限制在0.05到64之间,防止用户滚轮一滑把图缩成一个小点或者放大到坐标溢出。OnImageSourceChanged里重置视图,换图时回到 1:1,这是仿商业控件的行为;如果业务上希望换图保持视图,把这两行去掉就行。OnViewChanged里我一般还会做一次ClampOffset,让图像不会被拖出可视区太远,至少保留 48 像素在窗口内,否则用户很容易把图拖丢,只能靠「重置视图」菜单找回来。
RoiManager不建议做成依赖属性,它更像一个普通服务对象。RoiOverlay暴露一个RoiManager属性,MainWindow在 XAML 里绑上去,或者直接在后台代码里赋值。ROI 集合内部用ObservableCollection<RoiBase>,但RoiOverlay不监听集合的每一个变化去重建视觉树,而是在鼠标操作结束后统一InvalidateVisual。这样拖拽过程中不会因为集合通知触发大量布局计算,帧率会稳很多。
2.4 坐标系统:图像坐标、控件坐标、DIP 与像素
坐标系统是这套控件最容易埋雷的地方。我的原则是:ROI 模型里只存图像坐标,单位就是像素,左上角(0,0);控件坐标是 WPF 的 DIP,OnRender和鼠标事件拿到的都是 DIP;两者之间只通过ImageToControl和ControlToImage转换,任何地方都不要手写p.X * Scale + Offset.X。下面这个 mapper 是整条链路的核心:
public interface ICoordinateMapper { double Scale { get; } Point ImageToControl(Point imagePoint); Point ControlToImage(Point controlPoint); } public sealed class CoordinateMapper : ICoordinateMapper { public double Scale { get; init; } = 1.0; public Point Offset { get; init; } public Point ImageToControl(Point p) => new Point(p.X * Scale + Offset.X, p.Y * Scale + Offset.Y); public Point ControlToImage(Point p) => new Point((p.X - Offset.X) / Scale, (p.Y - Offset.Y) / Scale); }ImageToControl先乘Scale再加Offset,ControlToImage反过来先减Offset再除Scale。这两个方法是互逆的,后面单元测试会专门验证 round-trip。滚轮缩放时,为了让鼠标下的图像点不动,需要先记下鼠标位置的图像坐标,再算新的Offset:
protected override void OnMouseWheel(MouseWheelEventArgs e) { var controlPoint = e.GetPosition(this); var imagePoint = ControlToImage(controlPoint); var factor = e.Delta > 0 ? 1.1 : 1.0 / 1.1; var newScale = Math.Clamp(Scale * factor, 0.05, 64.0); // 保持鼠标下的图像点不动 Offset = new Point( controlPoint.X - imagePoint.X * newScale, controlPoint.Y - imagePoint.Y * newScale); Scale = newScale; e.Handled = true; }这段的逻辑是「以鼠标为锚点缩放」:先通过旧视图把鼠标位置转成图像坐标,再用新缩放算出让这个图像点仍然落在鼠标位置的Offset。factor取 1.1 和 1/1.1,滚轮一格大约 10%,手感比较接近商业控件;Math.Clamp和CoerceScale双重保险,防止外部直接设属性绕过限制。注意Offset是Point,Point + Vector在 WPF 里有运算符重载,但这里手动构造更直观。高 DPI 下e.GetPosition(this)仍然是 DIP,只要图像 DPI 固定 96,这套换算就是一致的;如果混用了PixelWidth和ActualWidth,就会出现「1080P 屏幕上 ROI 偏 1.25 倍」的玄学问题,第 4 章会专门讲。
3. ROI 绘制与交互:命中测试、句柄、拖拽状态机怎么串起来
3.1 ROI 模型设计:一个基类管住矩形、圆、多边形、线段
ROI 基类不需要太厚,但六个方法必须定清楚:怎么画、怎么命中、怎么移动、怎么缩放、句柄在哪、怎么序列化。我把命中测试的容差参数设计成图像坐标单位,而不是控件像素,这样缩放后不用改调用方。基类里只放Id、Name、Stroke、StrokeThickness、IsSelected这些公共状态,具体几何数据放子类。下面这个抽象类是整套 ROI 的地基:
public abstract class RoiBase { public string Id { get; set; } = Guid.NewGuid().ToString("N"); public string Name { get; set; } = "ROI"; public Color Stroke { get; set; } = Colors.Lime; public double StrokeThickness { get; set; } = 1.5; public bool IsSelected { get; set; } public abstract Rect ImageBounds { get; } public abstract void Draw(DrawingContext dc, ICoordinateMapper mapper); public abstract bool HitTest(Point imagePoint, double toleranceInImage); public abstract void Move(Vector imageDelta); public abstract void Resize(HandlePosition handle, Point imagePoint); public abstract IEnumerable<HandleInfo> GetHandles(ICoordinateMapper mapper); public abstract RoiDto ToDto(); } public enum HandlePosition { None, TopLeft, Top, TopRight, Right, BottomRight, Bottom, BottomLeft, Left } public record HandleInfo(HandlePosition Position, Point ControlPoint);Draw接收DrawingContext和ICoordinateMapper,ROI 自己负责把图像坐标转成控件坐标再画,这样RoiOverlay不用知道每种 ROI 的几何细节。HitTest的toleranceInImage由调用方用6 / Scale算出来,缩放越小容差越大,保证句柄在视觉上始终是 6 个像素左右。GetHandles返回的是控件坐标下的句柄位置,命中测试和绘制共用同一份数据,避免「画在这里、点在那里」的错位。ToDto返回纯数据对象,不依赖 WPF 类型,序列化时不会把Color、Point这些结构扯进去。
ImageBounds这个属性很关键,它返回 ROI 在图像坐标系下的外接矩形。移动、裁剪、判断 ROI 是否超出图像边界、导出 mask 都要用它。对于矩形 ROI,它就是Rect(TopLeft, BottomRight);对于圆,是Center ± Radius;对于多边形,是点集的最小外接矩形。基类不实现它,因为每种几何的算法不一样,但所有子类都必须给出一致的语义:左上角小于右下角,宽高非负。
3.2 矩形 ROI 实现与句柄:8 个控制点怎么算、Resize 怎么规范化
矩形 ROI 是最常用的,也是句柄最多的。8 个控制点分别是左上、上、右上、右、右下、下、左下、左。绘制时先画矩形边框,选中状态下再画 8 个 8x8 的小方块。命中测试先判断点是否落在矩形内部或边框容差内,句柄命中则放在控件层做,因为句柄大小是控件像素,不应该受图像缩放影响。下面这段是RectRoi的核心实现:
public sealed class RectRoi : RoiBase { public Point TopLeft { get; set; } public Point BottomRight { get; set; } public override Rect ImageBounds => new Rect(TopLeft, BottomRight); public override void Draw(DrawingContext dc, ICoordinateMapper mapper) { var p1 = mapper.ImageToControl(TopLeft); var p2 = mapper.ImageToControl(BottomRight); var pen = new Pen(new SolidColorBrush(Stroke), StrokeThickness); dc.DrawRectangle(null, pen, new Rect(p1, p2)); if (!IsSelected) return; var handlePen = new Pen(Brushes.Lime, 1); foreach (var handle in GetHandles(mapper)) { var rect = new Rect( handle.ControlPoint.X - 4, handle.ControlPoint.Y - 4, 8, 8); dc.DrawRectangle(Brushes.White, handlePen, rect); } } public override bool HitTest(Point p, double toleranceInImage) { var r = ImageBounds; var expanded = new Rect( r.Left - toleranceInImage, r.Top - toleranceInImage, r.Width + toleranceInImage * 2, r.Height + toleranceInImage * 2); return expanded.Contains(p); } public override void Move(Vector imageDelta) { TopLeft += imageDelta; BottomRight += imageDelta; } public override void Resize(HandlePosition handle, Point p) { var r = ImageBounds; double left = r.Left, top = r.Top, right = r.Right, bottom = r.Bottom; switch (handle) { case HandlePosition.TopLeft: left = p.X; top = p.Y; break; case HandlePosition.Top: top = p.Y; break; case HandlePosition.TopRight: right = p.X; top = p.Y; break; case HandlePosition.Right: right = p.X; break; case HandlePosition.BottomRight: right = p.X; bottom = p.Y; break; case HandlePosition.Bottom: bottom = p.Y; break; case HandlePosition.BottomLeft: left = p.X; bottom = p.Y; break; case HandlePosition.Left: left = p.X; break; } TopLeft = new Point(Math.Min(left, right), Math.Min(top, bottom)); BottomRight = new Point(Math.Max(left, right), Math.Max(top, bottom)); } public override IEnumerable<HandleInfo> GetHandles(ICoordinateMapper mapper) { var r = ImageBounds; yield return new HandleInfo(HandlePosition.TopLeft, mapper.ImageToControl(r.TopLeft)); yield return new HandleInfo(HandlePosition.Top, mapper.ImageToControl(new Point(r.Left + r.Width / 2, r.Top))); yield return new HandleInfo(HandlePosition.TopRight, mapper.ImageToControl(r.TopRight)); yield return new HandleInfo(HandlePosition.Right, mapper.ImageToControl(new Point(r.Right, r.Top + r.Height / 2))); yield return new HandleInfo(HandlePosition.BottomRight, mapper.ImageToControl(r.BottomRight)); yield return new HandleInfo(HandlePosition.Bottom, mapper.ImageToControl(new Point(r.Left + r.Width / 2, r.Bottom))); yield return new HandleInfo(HandlePosition.BottomLeft, mapper.ImageToControl(r.BottomLeft)); yield return new HandleInfo(HandlePosition.Left, mapper.ImageToControl(new Point(r.Left, r.Top + r.Height / 2))); } public override RoiDto ToDto() => new RoiDto("rect", Id, Name, new[] { TopLeft.X, TopLeft.Y, BottomRight.X, BottomRight.Y }, Stroke.ToString()); }Draw里new Rect(p1, p2)会自动规范化,即使p2比p1小也能画出正确的矩形;但Resize里不能依赖这个,必须手动Math.Min/Max,否则TopLeft和BottomRight的语义会反过来,后面的ImageBounds、Move、序列化全乱。GetHandles的 8 个点里,上、下、左、右四个中点是用r.Left + r.Width / 2算的,不要用TopLeft.X + (BottomRight.X - TopLeft.X) / 2,两者等价但前者在规范化后更稳。HitTest把矩形向外扩toleranceInImage,这样点击边框附近也能选中,用户体验比必须点中细线好很多。
ToDto里传的是double[],矩形四个数,圆三个数,多边形2N个数。反序列化时按Type字段走工厂,rect建RectRoi,circle建CircleRoi,polygon建PolygonRoi。DTO 里加一个Version字段,哪怕当前只有 1,后面加旋转矩形、带方向的线段时,旧文件也能走兼容分支。这个字段就是后悔药,等标注文件堆了几千份再想加就晚了。
3.3 鼠标状态机:Idle、Drawing、Dragging、Resizing 怎么切
鼠标交互最怕写成一堆if嵌套。我一般用一个DragMode枚举把状态收住:None、Pan、DrawRect、MoveRoi、ResizeRoi。OnMouseLeftButtonDown里按优先级判断:先看有没有点中当前选中 ROI 的句柄,再看有没有点中某个 ROI 内部,最后看空白处是不是在画新 ROI。句柄优先于 ROI 内部,ROI 内部优先于空白,这个顺序不能反,否则选中 ROI 后永远拖不动句柄。下面这段是核心状态切换:
private enum DragMode { None, Pan, DrawRect, MoveRoi, ResizeRoi } private DragMode _mode; private RoiBase _activeRoi; private HandlePosition _activeHandle; private Point _lastImagePoint; protected override void OnMouseLeftButtonDown(MouseButtonEventArgs e) { Focus(); CaptureMouse(); var controlPoint = e.GetPosition(this); var imagePoint = _mapper.ControlToImage(controlPoint); var tolerance = 6 / _mapper.Scale; _activeRoi = RoiManager.HitTest(imagePoint, tolerance); if (_activeRoi != null) { var handle = _activeRoi.GetHandles(_mapper) .FirstOrDefault(h => (h.ControlPoint - controlPoint).Length <= 6); _activeHandle = handle?.Position ?? HandlePosition.None; _mode = _activeHandle != HandlePosition.None ? DragMode.ResizeRoi : DragMode.MoveRoi; _lastImagePoint = imagePoint; RoiManager.Select(_activeRoi); InvalidateVisual(); e.Handled = true; return; } if (Tool == RoiTool.Rect) { var roi = new RectRoi { TopLeft = imagePoint, BottomRight = imagePoint }; RoiManager.Add(roi); _activeRoi = roi; _mode = DragMode.DrawRect; e.Handled = true; } } protected override void OnMouseMove(MouseEventArgs e) { var controlPoint = e.GetPosition(this); var imagePoint = _mapper.ControlToImage(controlPoint); var previous = _lastImagePoint; _lastImagePoint = imagePoint; switch (_mode) { case DragMode.DrawRect when _activeRoi is RectRoi rect: rect.BottomRight = imagePoint; InvalidateVisual(); break; case DragMode.MoveRoi when _activeRoi != null: _activeRoi.Move(imagePoint - previous); InvalidateVisual(); break; case DragMode.ResizeRoi when _activeRoi != null: _activeRoi.Resize(_activeHandle, imagePoint); InvalidateVisual(); break; } } protected override void OnMouseLeftButtonUp(MouseButtonEventArgs e) { if (_mode == DragMode.DrawRect && _activeRoi is RectRoi rect && (rect.ImageBounds.Width < 2 || rect.ImageBounds.Height < 2)) { RoiManager.Remove(rect); } _mode = DragMode.None; _activeRoi = null; _activeHandle = HandlePosition.None; ReleaseMouseCapture(); e.Handled = true; }OnMouseLeftButtonDown里先CaptureMouse,保证鼠标拖出控件边界还能收到MouseMove和MouseUp,否则快速拖拽时状态机会卡在MoveRoi出不来。tolerance = 6 / _mapper.Scale是把控件上的 6 像素容差换算成图像坐标,缩放 10 倍时容差变成 0.6 个图像像素,命中依然准。句柄命中用(h.ControlPoint - controlPoint).Length <= 6,这里比较的是控件坐标,所以不能用图像容差。OnMouseMove里先保存previous再更新_lastImagePoint,移动量用imagePoint - previous,顺序反了就会变成「越拖越快」或者「拖不动」。OnMouseLeftButtonUp里对太小的矩形做移除,避免用户单击一下留下一个 0x0 的 ROI,后面导出 mask 时全是空区域。
中键平移单独走一个分支,OnMouseDown里判断e.ChangedButton == MouseButton.Middle,把_mode设成Pan,记录_lastControlPoint,MouseMove里改Offset。右键用来取消当前绘制,OnMouseRightButtonDown里如果_mode == DrawRect就把刚建的 ROI 移除,状态复位。这些分支不要和左键状态机混在一个switch里,分开写更清楚。
3.4 绘制层:用 DrawingVisual 还是 OnRender
ROI 数量少的时候,直接在RoiOverlay.OnRender里遍历RoiManager.Items调Draw最简单,代码少,调试直观。数量超过两三百个,或者每个 ROI 的点很多,全量重绘就会吃 CPU,这时候再考虑DrawingVisual缓存。我的做法是先上OnRender,用性能计数器看帧率,真的卡了再换,不提前优化。下面这段是RoiOverlay的绘制入口和RoiManager的倒序命中:
protected override void OnRender(DrawingContext dc) { base.OnRender(dc); if (RoiManager == null) return; foreach (var roi in RoiManager.Items) roi.Draw(dc, _mapper); DrawDebugOverlay(dc); } public sealed class RoiManager { public ObservableCollection<RoiBase> Items { get; } = new(); public RoiBase Selected { get; private set; } public void Add(RoiBase roi) { Items.Add(roi); Select(roi); } public void Remove(Roi <p> <a href="https://download.csdn.net/download/weixin_43678368/90990432" style="color:#ec7500;font-size:14px;"> 本文还有配套的精品资源,点击获取 </a> <img alt="menu-r.4af5f7ec.gif" src="https://csdnimg.cn/release/wenkucmsfe/public/img/menu-r.4af5f7ec.gif" style="width:16px;margin-left:4px;vertical-align:text-bottom;cursor:text;"> </p>