资讯详情

资讯详情

FastColoredTextBox中文修正:重影、光标与IME候选框

简介FastColoredTextBox 中文修正版 V2 面向使用 WinForms 开发代码编辑器、配置编辑器或语法高亮控件的 C# 开发者重点修复原版中文双字节显示异常、光标定位错位以及多 Style 叠加时的样式错乱问题。压缩包共 463 个文件约 17.01MB包含 125 个 cs 源码、61 个 resx 资源、40 个 vb 示例及 png、dll、xml、sln 等配套文件覆盖控件源码、示例工程、资源与构建配置便于直接编译调试和对照修改。已有 1763 人学习下载。读者可获得可运行的修正版源码、完整示例与工程结构快速定位中文渲染、光标测量和多高亮样式冲突的处理方式减少在富文本代码控件上重复踩坑适合需要稳定中文编辑体验的桌面项目集成参考。1. 粘贴中文就重影、光标乱跳这套控件的坑从哪来一段中文粘进 FastColoredTextBox屏幕上立刻出现三种毛病字和字叠在一起像重影光标点下去停在错的位置选中一行高亮框只盖住文字的前半截。这三种都不抛异常、不打日志只是眼睛看着不对所以排查起来格外折腾很容易被误判成字体问题或者显卡渲染问题改半天 Font 属性一点用没有。这份中文修正版 V2 针对的正是这三个点中文显示、光标位置、样式错位。它适合正在用这套控件做配置编辑器、脚本编辑框、日志查看器的开发者尤其是界面里必须出现中文、又不想为了中文换掉整个编辑控件的场景。下面按故障类型拆开讲先说清为什么会这样再落到具体改哪几行。2. 中文显示成方块、字叠字字体回退与双宽字符测量2.1 先把三种「看不见中文」分开别混成一个问题很多人一看到中文显示不正常就去改字体改完发现方块变成了乱码方块问题还在。实际上「中文显示异常」至少是三种不同的故障触发条件和修法完全不同。第一种是方块字体里没有对应字形系统画一个空心方框顶上这时字符宽度是对的只是没有形。第二种是空白字形有但前景色和背景色撞了或者被背景绘制盖住看起来像什么都没画。第三种是叠字/重影字形正常但每个字符的落点算错了后一个字符压在前一个字符身上。这三种里只有第三种跟控件的中文修正有关前两种是字体和配色问题。区分方法很简单把鼠标拖过去选中那一行。如果选区高亮范围正常、只是里面没字形那是字体问题如果选区高亮本身就短了一截、跟文字对不上那就是落点计算错了属于控件层面的宽度模型问题。我一般先用这个动作做一次二分能省掉一半的无效尝试。第三种故障的根因在控件的绘制公式上。FastColoredTextBox 画字符时按x LeftPadding (i - lineStart) * CharWidth算落点其中CharWidth是启动时测出来的一个常量代表「一个字符多宽」。这个假设在英文等宽字体下完全成立因为每个 ASCII 字符确实都等于CharWidth。但一个汉字的实际宽度大约是 ASCII 字符的两倍公式里却仍然只前进一个CharWidth于是第二个汉字直接画到了第一个汉字身上视觉上就是糊成一团的重影。行内汉字越多重叠越密集一整行中文看上去就像被压扁了。2.2 字体回退链设了 Consolas 中文就消失是怎么回事代码编辑器默认喜欢用 Consolas、Courier New 这类等宽字体它们对英文的显示效果确实好但这些字体本身不含 CJK 字形。Windows 有一套字体链接font linking机制理论上缺字形时会自动回退到系统默认的中文字体但这套机制在 GDI 和 GDI 两条绘制路径上的表现并不一致用TextRenderer.DrawTextGDI走链接用Graphics.DrawStringGDI可能就不走结果就是同一个控件里一部分文字有中文、一部分是方块。常见的做法是显式指定一个同时覆盖英文和中文的字体而不是依赖回退。微软雅黑 UI 在等宽性上不如 Consolas但对中英文混排的兼容性最好如果一定要保留等宽的英文观感就得自己在测量阶段处理回退不能指望系统帮你兜底。// 显式指定字体避免依赖系统 font linking // 关键点字体一旦确定所有宽度都必须基于这个字体测量不能中途换 var editorFont new Font(Microsoft YaHei UI, 10f, FontStyle.Regular, GraphicsUnit.Point); // 如果确实想用 Consolas 的英文观感就只在英文字符上用它 // 中文字符统一走回退字体两者宽度都要缓存 private Font GetFontFor(char c) { return IsWideChar(c) ? _cjkFont : _asciiFont; }这里有两个参数值得说。字号用10f而不是9是因为整数磅值在 125%、150% 缩放下换算成像素时取整误差更明显用浮点能少一层损失。GraphicsUnit.Point必须显式写出来虽然它也是默认值但一旦有人从别处复制了一段用GraphicsUnit.Pixel的代码混进来同一份配置在不同 DPI 下测出来的宽度会差 30% 以上这种问题排查起来极其费时。提示换字体之后一定要把所有跟宽度、行高相关的缓存全部失效重算否则你会看到一个更诡异的现象——字体明明换了行高还是旧的最后一行被裁掉半截。2.3 用 TextRenderer 逐字符测宽把 CharWidth 从常量变成查表知道了根因修法就有两条路。一条是「给中文乘 2」另一条是「逐字符测量并缓存」。前者看起来省事实际上只能对付纯汉字遇到全角逗号、全角括号、日文假名、韩文谚文、emoji 就会翻车因为这些字符的宽度各不相同有的接近一个 ASCII有的接近两个乘 2 反而制造新的错位。后者多花一点内存但一劳永逸。另一个容易被忽略的细节是测量和绘制必须用同一套 API。原先的CharWidth是用Graphics.MeasureString测的GDI而实际绘制走的是TextRenderer.DrawTextGDI两条路径对同一个字符串给出的宽度能差出每字符 1 到 2 像素。单看一个字符无所谓一行 60 个字符累积下来就是 60 到 120 像素的偏差这正好解释了「为什么行尾的字偏得最厉害」。// 双宽字符判定覆盖 CJK 汉字、假名、谚文、全角标点等东亚宽字符区段 internal static bool IsWideChar(char c) { return (c 0x1100 c 0x115F) // 谚文字母 || (c 0x2E80 c 0x303E) // 部首、康熙部首、CJK 标点 || (c 0x3041 c 0x33FF) // 假名、注音、CJK 兼容 || (c 0x3400 c 0x4DBF) // 扩展 A 区汉字 || (c 0x4E00 c 0x9FFF) // 基本汉字区 || (c 0xA000 c 0xA4CF) // 彝文 || (c 0xAC00 c 0xD7A3) // 谚文音节 || (c 0xF900 c 0xFAFF) // CJK 兼容汉字 || (c 0xFE30 c 0xFE4F) // CJK 兼容形式 || (c 0xFF00 c 0xFF60) // 全角 ASCII || (c 0xFFE0 c 0xFFE6); // 全角符号 } // 按字符缓存实际宽度命中缓存直接返回避免每帧重复测量 private readonly Dictionarychar, int _widthCache new Dictionarychar, int(); internal int GetCharWidth(char c) { if (_widthCache.TryGetValue(c, out var w)) return w; // 用与绘制完全相同的 TextRenderer 测量保证测出来的就是画出来的 w TextRenderer.MeasureText( c.ToString(), GetFontFor(c), new Size(int.MaxValue, LineHeight), TextFormatFlags.NoPadding | TextFormatFlags.NoPrefix).Width; _widthCache[c] w; return w; }TextFormatFlags.NoPadding是最关键的一个参数。默认情况下TextRenderer.MeasureText会在文本四周加内边距每个字符多出 1 到 3 像素几十个字符累积起来就是几十像素的偏移而且这个偏移跟字符内容有关表现得极不规律。NoPrefix则用来关掉「 被当成助记符前缀吃掉」的行为代码编辑器里、 0xFF这类写法非常常见不加这个标志测量结果会莫名其妙地少一个字符宽度。缓存这一层也值得多说一句。Dictionarychar, int的 key 是单个char对于 BMP 内的字符够用但 emoji 和部分生僻汉字在 UTF-16 里是代理对两个char组成一个字符单字符查表会把它们拆成两半测出两个半宽加起来正好等于一个全宽视觉上勉强能看但光标定位会落在这两个char中间删除时只删一半直接产生乱码字符。修正版里对代理对做了合并处理先判断char.IsHighSurrogate再取下一个char一起测。3. 光标总偏右几格、选区框不住字PointToPosition 的等宽假设3.1 偏移量随中文个数线性增长这是个可以自己验证的规律鼠标点在某一行中间光标没有出现在点击位置而是跑到了这一行的后半段甚至行尾。这个现象有一个非常稳定的特征偏差大小与点击位置之前的中文个数成正比。英文行点击基本准一个汉字的行偏一格左右十个汉字的行偏十格左右。你可以做一次简单验证打开一个中英混排文件在纯英文行点行首点行尾各一次再在纯中文行做同样的动作记录光标实际落点偏差曲线几乎是直线。这个规律说明问题不在「字符宽度测得准不准」而在「定位公式用不用得上这个宽度」。PointToPosition的原始实现大致是拿鼠标 x 坐标减去左边距再除以CharWidth得到列号本质上还是那个等宽假设。既然一个汉字占两个CharWidth那么点击第 3 个汉字时公式算出的列号大约是 6光标自然就跳到右边去了。顺带说一个反直觉的地方这个 bug 在「输入」时不明显在「点击」和「拖选」时特别明显。因为键盘输入主要靠CaretIndex顺序推进不太依赖坐标反算而鼠标操作必须靠坐标反算误差就全暴露出来了。所以有人反馈「打字没问题就是点不准」这不是玄学是两条代码路径的差异。3.2 PointToPosition 改成逐字符累加别再乘 CharWidth修法是把「乘法」换成「累加」。既然每个字符的真实宽度已经能查表拿到那就从行首开始一个个加过去加到哪一格超过鼠标位置就返回那一格。这样无论字符是半宽、全宽还是三宽部分 emoji 场景定位都天然正确不需要为每种宽度写特例。// 鼠标坐标 - 字符索引逐字符累加真实宽度替代 (x - LeftPadding) / CharWidth public int PointToCharIndex(Point point) { int line FindLineByY(point.Y); // 先按行高定位逻辑行 if (line 0) return -1; int start GetLineFirstCharIndex(line); // 该行首字符在全文中的索引 int lineLen GetLineLength(line); int x point.X - LeftPadding; // 换算到文本区内的相对坐标 int acc 0; for (int i 0; i lineLen; i) { int w GetCharWidth(this[start i]); // 落在字符前半段算命中本字符落在后半段算下一个字符 if (x acc w / 2) return start i; acc w; } return start lineLen; // 超出本行末尾落在行尾 }那个w / 2是整段代码里最容易被改错的地方。如果写成x acc w点击一个汉字的后半部分会跳到下一个字符用户会觉得「光标插不进去」尤其是想在一个汉字后面追加内容时怎么点都点不中。如果写成x acc那就是永远只能点在字符左边缘输入体验更差。用半格判定行为跟 Windows 自带的记事本、VS Code 一致用户不会觉得别扭。FindLineByY这一步也有个坑。行高必须用当前字体重新算不能沿用启动时缓存的LineHeight。中文行和英文行如果字体不同行高可能不一样即使字体相同换行符类型不同\r\n和单独的\n也会影响行数统计。修正版里把行高和行首索引都做了按需重算代价是每次布局变化要多走一遍文本但换来的稳定性值得。3.3 选区与语法高亮按显示宽度重算而不是按字符数光标定位修好了选区高亮还会错。原因在于高亮矩形的宽度是另一段代码算的很多实现里写的是(end - start) * CharWidth又是一个乘法。结果就是文字位置对了包住文字的高亮框还是短的看起来像「选中了一半」。最省事也最不容易再出错的写法是复用定位函数不要另起一套计算逻辑。既然PositionToPoint已经能把字符索引转成准确坐标那就直接用它算两个端点再相减绘制和命中共用同一套宽度模型天然不会脱节。// 选区高亮矩形直接复用字符索引 - 坐标的换算避免两套宽度逻辑打架 private Rectangle GetSelectionRect(int startChar, int endChar) { Point p1 PositionToPoint(startChar); // 选区起点客户区坐标 Point p2 PositionToPoint(endChar); // 选区终点 int lineHeight GetLineHeight(p1.Y); // 按起点所在行取行高 // 跨行选区要单独处理这里只演示同一行内的情形 return new Rectangle(p1.X, p1.Y, Math.Max(1, p2.X - p1.X), lineHeight); }Math.Max(1, ...)这一层保护看着多余实际很有用空选区或者零宽字符比如零宽空格 U200B从网页复制代码时经常带进来会导致宽度算成 0矩形宽度为 0 时某些 GDI 绘制分支会直接不画表现出来就是「有时候选中了但看不见高亮」又是一个看起来像玄学的问题。语法高亮走的是另一条路但根因相同。高亮器给每个 token 标一个样式索引绘制时按索引取颜色和字体。如果某个样式用了FontStyle.Bold这个 token 的实际字符宽度就会比常规字重宽一点点而宽度缓存里只按字符和字体名存了一份没有区分字重于是加粗的关键字后面的中文就会整体右移几像素。这个问题在短行上看不出来长行上非常明显而且是「只有含关键字的行才偏」这种规律性极强的表现。4. 输入法候选框飘到窗口外WM_IME_COMPOSITION 没接住4.1 组合串提交之前CaretIndex 指向的位置是「假」的用中文输入法打字拼音还没上屏时那串字母叫组合串composition string。组合串在提交之前并不属于控件的文本内容它是由输入法自己在窗口上画出来的。这就带来一个麻烦此时CaretIndex指向的是组合串的起点而用户眼睛看到的光标已经跟着待选字符走到后面去了。如果你在这个时刻用PositionToPoint(CaretIndex)去定位候选框框会钉在组合串开头而不是用户正在看的位置。这个偏差在纯英文输入时完全不存在所以很多人根本没意识到有这回事直到有用户反馈「打拼音时候选框跑到左边去了」才发现。更麻烦的是它跟输入法实现有关微软拼音、搜狗、QQ 拼音的行为不完全一致有的会在组合期间更新插入符位置有的不会。还有一个更隐蔽的坑控件里通常有个imeAllowed之类的开关本意是控制要不要处理 IME 消息。有人因为候选框位置乱干脆把它设成false位置确实不乱了——因为中文根本打不进去了。这种「用一个更大的故障掩盖小故障」的操作在排错现场非常常见值得单独警惕。4.2 ImmSetCompositionWindow 的三个参数怎么填正确做法是保留 IME 消息处理并在组合开始时主动把候选框位置告诉输入法。这需要调用ImmGetContext拿到输入法上下文填一个COMPOSITIONFORM结构再ImmSetCompositionWindow设置回去。三个参数里dwStyle决定用哪种定位方式ptCurrentPos是定位点第三个成员rcArea只在CFS_RECT或CFS_EXCLUDE模式下才用得上。// 在 WndProc 里拦 IME 消息把候选框钉到当前插入符位置 protected override void WndProc(ref Message m) { const int WM_IME_STARTCOMPOSITION 0x010D; const int WM_IME_COMPOSITION 0x010F; const int CFS_POINT 0x0002; if (m.Msg WM_IME_STARTCOMPOSITION || m.Msg WM_IME_COMPOSITION) { // 组合串起点 已输入的组合字符宽度 视觉上真正的位置 int visualIndex CaretIndex _compositionCharCount; Point p PositionToPoint(visualIndex); // 必须返回客户区坐标 IntPtr hIMC ImmGetContext(Handle); if (hIMC ! IntPtr.Zero) { var cf new COMPOSITIONFORM { dwStyle CFS_POINT, // 只钉一个点不画输入法自带边框 ptCurrentPos new POINT { X p.X, Y p.Y } }; ImmSetCompositionWindow(hIMC, ref cf); ImmReleaseContext(Handle, hIMC); // 必须释放否则句柄泄漏 } } base.WndProc(ref m); }dwStyle用CFS_POINT而不是CFS_RECT是因为CFS_RECT会让输入法在指定矩形内自绘一个带边框的候选区域在深色主题下那个边框经常是白底黑框直接糊在代码上非常难看。用CFS_POINT只告诉它「从哪开始」具体怎么画交给输入法自己按系统主题来兼容性最好。ImmReleaseContext这一行绝对不能省。每次组合开始都ImmGetContext一次而忘了释放输入法上下文句柄会持续累积。短时间打字看不出来连续输入几十分钟之后会出现候选框彻底不显示、切换输入法卡死、甚至整个窗口失去响应。这类问题复现条件模糊用户往往描述成「用久了就卡」很难联想到是句柄泄漏。坐标必须是客户区坐标不是屏幕坐标。这个错误特别容易发生在高 DPI 场景下如果混用了PointToScreen在 150% 缩放下候选框会偏到右下角偏移量正好是缩放比乍一看还以为是缩放算错了实际上是坐标系用错了。4.3 全角标点、emoji 与代理对宽度计算的最后一公里前面两节解决的是「大部分中文」的问题剩下那一小撮字符会以各种奇怪的方式漏网。全角标点。在 Unicode 里属于FF00到FF60区段宽度是一个汉字的宽度但很多宽度判定函数只写了「汉字区段」把它们漏了结果就是句子里的逗号之后所有字符都往左偏一格。这个偏差在长段落里看不出来在只有一两个标点的短行上反而显眼。emoji 更麻烦。大部分 emoji 在 UTF-16 里是两个char组成的代理对某些组合 emoji比如带肤色的手势、家庭组合甚至是四到七个char加零宽连接符拼起来的。如果按单个char计宽度一个 emoji 会被拆成两个半宽累加总宽恰好等于全宽看起来没问题但光标可以落在这两个char中间按退格只删掉一半屏幕上立刻出现一个「」用户就会说「中文修正版有 bug」。// 代理对与零宽字符的处理一次吃掉一个完整码位而不是一个 char private int GetCodePointWidth(int charIndex) { char c this[charIndex]; // 高代理 低代理 一个完整码位宽度按整码位算 if (char.IsHighSurrogate(c) charIndex 1 TextLength char.IsLowSurrogate(this[charIndex 1])) { return GetCharWidth(c) GetCharWidth(this[charIndex 1]); } // 零宽字符不占位但也不能返回负数否则累加时会破坏单调性 if (c \u200B || c \uFEFF) return 0; return GetCharWidth(c); }零宽字符返回 0 而不是跳过是为了保证「索引到坐标」这个映射的单调性不被破坏。如果直接把零宽字符从累加里剔除某些边界情况下会出现两个不同索引映射到同一个坐标鼠标点击时前后光标来回抖。这类细节不影响绝大多数场景但一旦触发表现就是「光标在某个位置点不进去」排查成本极高。5. 避坑记录改完宽度之后更乱的五种情况5.1 字体与宽度三条改完反而更花的记录记录一换字体后最后一行被裁掉一半。现象是换了微软雅黑之后中文能显示了但滚动到底部时最后一行只露出上半截。原因不在宽度而在行高LineHeight是启动时按Font.Height算出来缓存着的换字体只触发了重绘没有触发重算可见行数还是按旧行高算的导致最后一行被切。解决办法是重写OnFontChanged在里面一次性清空宽度缓存、重算行高、重算可见行数三件事必须绑在一起做。protected override void OnFontChanged(EventArgs e) { base.OnFontChanged(e); _widthCache.Clear(); // 宽度缓存全部失效 RecalculateLineHeight(); // 行高重算 RecalculateVisibleLines();// 可见行数重算否则滚动条范围会错 Invalidate(); // 三件事做完再统一重绘 }记录二关键字一加粗后半行整体右移。现象是某几行文字明显比别的行偏右而且只有含关键字的行才有这个问题。原因是宽度缓存只按字符和字体名存没区分字重FontStyle.Bold的实际宽度比常规字重大一点点绘制时按常规宽度画就偏了。解决有两条路一是高亮样式干脆不用加粗只改前景色二是把缓存 key 改成(char, FontStyle)的组合按字重分别缓存。前者更省事后者更还原。记录三把系统缩放调到 150% 之后中文越往后越偏。现象是同一段文字在 100% 缩放下正常在 150% 下每行末尾偏十几像素。原因是CharWidth是 int缩放后算出来是 8.4 像素被截断成 8一行 40 个字符就累积偏差 16 像素。解决办法是内部累加全程用float只在最终绘制和命中判定时取整同时在DpiChanged事件里清空所有缓存重新测量。开启 PerMonitorV2 之后这个事件才可靠触发单显示器时代那套写法在双屏不同缩放的机器上会彻底失效。5.2 光标与选区只在特定操作之后才复现的两条记录记录四拖选时高亮块比文字短一截但点击定位是准的。这个组合特别能说明问题定位准说明PositionToPoint修好了高亮短说明绘制选区的代码还在用老公式(end - start) * CharWidth。两套宽度逻辑并存就是这类 bug 的典型形态。改法是让选区绘制直接调用PositionToPoint算两端再相减全项目搜一遍* CharWidth把所有残留的乘法清干净只留一处宽度来源。// 排查用把还在用乘法算宽度的地方找出来一个个改成累加 // git grep -n \* *CharWidth -- *.cs var rect GetSelectionRect(SelectionStart, SelectionEnd); // 改后复用定位函数 // var width (SelectionEnd - SelectionStart) * CharWidth; // 改前等宽假设记录五粘贴进来的文本看着正常一按方向键光标就跳。现象很怪刚粘进来的内容显示完全正常光标也在对的位置但只要按一次左右方向键光标就跳到几个字符之外。原因是粘进来的文本混了单独出现的\r老式换行符和全角空格 U3000行首索引表按\n切分遇到单独\r时行列对应关系就错位了。解决是粘贴入口统一做一次规范化\r\n和\n统一成\r\n单独\r替换掉U3000 不删但必须走宽字符测量路径。顺手把不可见字符做成可选的显示开关下次再遇到类似问题一眼就能看出来。注意以上五个坑里有三个都是「改了一半」造成的不是原始 bug。改这类控件最忌讳只修看得见的那一处宽度模型一旦动所有依赖它的地方都得跟着过一遍。6. 三分钟定位把「中文异常」拆成四个可验证的观察点排这类问题不需要一上来就读完整份源码先做四个动作基本能把故障范围缩到一小块代码里。观察点一CtrlEnd 到文末看光标停在哪。正常应该停在最后一个字符的右边缘。如果光标停在最后一个字的里面或者右边老远说明是宽度模型的问题跟绘制无关直接去查GetCharWidth和PositionToPoint。如果光标位置对但字是方块那是字体去查字体回退链。观察点二分别在纯英文行和纯中文行点三次行尾记录偏差格数。偏差跟中文个数成正比就是等宽假设没破偏差是个固定值那多半是LeftPadding或者内边距没扣干净偏差随行号增大而增大那是高 DPI 下的取整累积。观察点三把系统缩放改到 150%重启程序重复观察点二。100% 下正常、150% 下偏问题在取整和 DPI 事件处理两个缩放比下都偏同样的像素数问题在坐标系用错客户区 vs 屏幕坐标。观察点四切中文输入法打三个字但不提交看候选框位置。候选框钉在组合串开头是没处理WM_IME_COMPOSITION候选框跑到窗口外是坐标算成了屏幕坐标候选框根本不出现先查ImmReleaseContext有没有漏再查imeAllowed有没有被人设成false。观察到的现象最可能的根因先看哪里字叠字、越往后越糊落点用乘法而非累加PointToPosition及其调用方中文变方块选区范围正常字体缺 CJK 字形字体回退链点击位置偏打字正常坐标反算依赖等宽假设PointToCharIndex高亮比文字短选区宽度仍在用乘法绘制选区那段输入法候选框位置乱IME 消息没接或坐标错WndProc里的 IME 分支这套顺序我用了很久基本上四个动作做完就能确定该翻哪个文件。那份修正版 V2 把上面提到的宽度缓存、逐字符累加、选区复用定位、IME 组合串偏移这几处都改在源码里了拿到之后直接替换原来的控件源文件、重新编译即可调用方的new FastColoredTextBox()和各种属性设置都不用动语法高亮器的配置也不用重写。从那以后我每次接这类编辑器控件的活儿都强制先把「宽度、定位、IME」这三个断点各跑一遍再动手改代码哪怕需求只是「让中文能显示」——因为这三处只要有一处没对齐后面所有的修修补补都会变成拆东墙补西墙。希望帮到你。本文还有配套的精品资源点击获取
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →