简介:FastColoredTextBox中文修正版V2是一份针对开源代码编辑器控件做的中文兼容修正包,主要面向在WinForms桌面应用中集成代码编辑能力、且需要正确显示中文的开发者。原作者留下的问题是中文双字节字符显示异常、光标定位不准,多个Style叠加后样式错位,本版已针对这些缺陷逐一修复,并保留了对C#、HTML等语言的默认高亮支持。压缩包共463个文件,包含125个C#源文件、大量资源文件、工程与解决方案、编译后的DLL和可运行示例,整体约17.01MB,解压后可在Visual Studio中直接打开Tester工程编译体验。已有1762人学习下载,适合希望通过对照源码了解中文渲染与样式合并机制的开发者,也可以直接引用修复组件省去二次排查的麻烦。包内还附有VB工程和多种高亮示例,方便中高级开发者按需扩展或迁移到其他.NET项目。
1. FastColoredTextBox 中文修正版 V2 到底修了什么:一次点击不准引发的像素级排查
FastColoredTextBox 中文修正版 V2 这个标题,我第一反应不是“又有人做了个汉化包”,而是“终于有人把光标点不准这个历史问题啃下来了”。做 WinForms 语法高亮编辑器的开发者大多有过这种翻车经历:控件英文用得好好的,一混排中文,光标总落在偏后的字符上,注释高亮的背景色和文字对不齐,切到深色主题后整行样式右飘几个像素。这三个现象指向同一个根因——控件内部每行维护的字符宽度缓存对 CJK 字符的度量有偏差,而 V2 修正的正是中文显示、光标位置、样式错位这三个维度。它本质上是一次像素级度量修正,不是翻译,也不是换肤。这篇文章适合正在维护内部工具编辑器、自研 SQL 脚本编辑器,或者准备把 FastColoredTextBox 集成进中文本地化产品的 WinForms 开发者。
2. FastColoredTextBox 的文本度量模型:中文错位的根因在字符宽度表
2.1 字符宽度表与行快照:控件“记住”每个字符占多宽
FastColoredTextBox 是自绘控件,为了在滚动、命中测试、语法高亮时保持流畅,它不会在每次绘制时都去问 GDI“这个字符多宽”,而是为每一行维护一个字符宽度数组。这个数组通常叫做 CharWidths,长度等于该行字符数再加一,每一格存的是对应字符在控件当前字体下的绘制宽度,单位是像素。末尾多出来的一格用来存整行累计宽度,这样光标定位时就能用二分查找代替线性累加。
中文错位的第一个要害就在这张表里。英文字符宽度窄,全角中文字符宽度接近两倍,如果表里某个 CJK 字符的宽度值比实际绘制宽度小 1~2 像素,那么从这个字符之后的所有字符在命中测试、高亮绘制、水平滚动上都会累积偏移。英文场景下 1 像素的误差无感,中文全角字的右半段却足够让光标选中下一个字符。
我一般会先做一次“体检”来确认问题是不是出在宽度表上。下面的代码把某行的缓存宽度和 TextRenderer 实际测出来的宽度做对比:
// 体检:打印某一行的字符宽度表,排查中文条目 private void DumpCharWidths(FastColoredTextBox tb, int lineIndex) { var line = tb.Lines[lineIndex]; var chars = line.Text.ToCharArray(); for (int i = 0; i < Math.Min(chars.Length, 80); i++) { float cached = line.CharWidths[i]; // 控件缓存里的宽度 float measured = TextRenderer.MeasureText( chars[i].ToString(), tb.Font, new Size(int.MaxValue, int.MaxValue), TextFormatFlags.NoPadding | TextFormatFlags.NoPrefix).Width; if (Math.Abs(cached - measured) > 1f) { Debug.WriteLine($"第{lineIndex}行 索引{i} 字符'{chars[i]}' 缓存={cached} 实测={measured}"); } } }这段代码有几个关键参数要注意。TextRenderer.MeasureText 是修复动作里的基准测量 API,后面的 TextFormatFlags.NoPadding 表示去掉 GDI 为文本渲染额外加的左右内边距,NoPrefix 表示不要处理“&”快捷键前缀;这两个标志不加上,测出来的宽度会和控件绘制时的实际渲染不一致。阈值取 1f 是因为同一个字符在缓存和实测之间如果只差 0.5 像素可以忽略,超过 1 像素基本就会在光标定位上体现出来。打印上限取 80 字符是因为体检只需要抽样,不必把整行都打出来,大文件场景下全量打印反而刷屏。
2.2 TextRenderer 与 Graphics 两种度量:CJK 字符差 1~2 像素的根源
FastColoredTextBox 的绘制管线走的是 TextRenderer,也就是 GDI 的 DrawText 那一套;但控件里某些历史代码路径在计算字符宽度时用的是 Graphics.MeasureString,也就是 GDI+ 的度量方式。这两套 API 对同一个 TrueType 字体的处理差异,就是中文错位的第二个要害。
GDI 的文本度量是像素栅格化之后的 advance 宽度,带四舍五入,值往往比较“整”;GDI+ 的 MeasureString 走矢量轮廓,返回浮点值,还包含字符间微调。对 Consolas、Courier New 这类等宽拉丁字体,两者结果几乎一致,所以在纯英文环境里怎么混用都不出问题。一旦出现“宋体”“微软雅黑”这类中文字体,同一个 12px 字号,两种方式测出来的全角宽度可能差 1~2 像素,个别字体下甚至差 3 像素。
修复这类版本问题的原则很简单:测量和绘制必须走同一条管线。我在集成时会把所有字符宽度测量统一收口到一个静态方法里,避免各写各的:
// 统一测量入口:全工程只认这一个 API private static float MeasureCharWidth(char c, Font font) { return TextRenderer.MeasureText( c.ToString(), font, new Size(int.MaxValue, int.MaxValue), TextFormatFlags.NoPadding | TextFormatFlags.NoPrefix).Width; }这里还要处理两个特例。空格好办,直接测;制表符不能直接测,它的宽度不是固定值,而是从当前 x 坐标到下一个 TabStop 的差值。常见做法是先测一个空格宽度,再按 TabStop 间隔取整。如果不做这个处理,行首缩进用制表符的代码文件在光标定位时会明显偏左或偏右。
2.3 字体回退与全角判定:为什么相同字体下英文正常中文错
第三个要害是字体回退。TextRenderer 在绘制时如果发现当前字体里没有某个字符,会走系统的字体链接机制,自动用备用字体渲染。典型场景是主字体设成 Consolas,中文字符实际是被“微软雅黑”或“等线”绘制出来的,不是 Consolas 画的。问题在于:绘制时走了回退,宽度测量时如果没有走同一套回退逻辑,测出来的是主字体的宽度,画出来的是回退字体的字形,两者宽度不一致,错位就从这里来。
所以在修复中文显示时,我先要能判断哪些字符属于 CJK,再决定测量策略。常用判断范围是 0x2E80 到 0x9FFF,覆盖 CJK 部首、常用汉字、韩文兼容区;扩展 B 区以外的生僻字和 emoji 走的是 UTF-16 代理对,需要额外处理:
// 全角/汉字判断:常见做法是直接按码位区间走 private static bool IsCjkChar(char c) { return c >= 0x2E80 && c <= 0x9FFF; }对代理对的处理要特别注意,FastColoredTextBox 的 CharWidths 数组按 UTF-16 码元计数,一个 emoji 占两个数组格。如果只按单字符测量,高代理项和低代理项各自都会返回一个异常宽度,累加后整行偏移。正确做法是把代理对当作一个整体测量,宽度记在高代理项那一格,低代理项那一格记 0,绘制和命中测试都按这个约定走。这一段逻辑是中文修正版里最容易遗漏的细节,后面踩坑章节还会回到这里。
3. 修复中文显示与光标位置:字体回退、宽度表重建与命中测试换算
3.1 复现中文错位的最小工程
动手修之前,先搭一个最小复现工程当基准。这个工程不需要任何业务代码,只做一件事:让中英文混排文本在 FastColoredTextBox 里显示出来,然后反复点击中文字符的左右半段,观察光标落点。我一般直接用 Consolas 作为主字体,因为它拉丁字符窄、中文字符回退到系统中文字体后宽度差异最明显,最容易暴露问题:
// 最小验证工程:新建一个 Form,放一个 FastColoredTextBox var tb = new FastColoredTextBox { Dock = DockStyle.Fill, Font = new Font("Consolas", 12f), // 故意用英文字体触发字体回退 Language = Language.Custom // 不挂内置高亮规则,排除干扰 }; tb.Text = "public void 测试中文混排()\n{\n string 姓名 = \"张三\"; // 注释\n}"; this.Controls.Add(tb);跑起来后做三组手测:点击“测”字左半段,光标应落在“测”字前面;点击“测”字右半段,光标应落在“测”和“试”之间;点击行尾空白处,光标应落在行尾而不是回退到上一个字符。如果右半段点击后光标跑到了“试”字后面,说明命中测试的左边界判定出了问题。这个工程后面每一轮修改都要重新跑一遍,它比任何单元测试都直观。
3.2 重建字符宽度表:逐字符测量代码
确认宽度表有偏差后,就按统一测量 API 重建每行的 CharWidths。注意,这里不是一次性全量重建,而是提供重建单行的方法,由事件处理器决定哪些行需要重建:
// 重建单行字符宽度表,供 TextChanged / FontChanged 调用 private void RebuildLineWidths(Line line, Font font) { string text = line.Text ?? ""; int n = text.Length; float[] widths = new float[n + 1]; // 最后一位存整行累计宽度 float total = 0f; for (int i = 0; i < n; i++) { char c = text[i]; float w; if (char.IsHighSurrogate(c) && i + 1 < n && char.IsLowSurrogate(text[i + 1])) { // 代理对整体测量,宽度记在高代理项上,低代理项记 0 w = TextRenderer.MeasureText(text.Substring(i, 2), font, new Size(int.MaxValue, int.MaxValue), TextFormatFlags.NoPadding | TextFormatFlags.NoPrefix).Width; widths[i++] = w; widths[i] = 0f; } else if (IsCjkChar(c)) { w = Math.Max(MeasureCharWidth(c, font), 1f); // 防止个别字体回退返回 0 } else { w = MeasureCharWidth(c, font); } widths[i] = w; total += w; } widths[n] = total; line.CharWidths = widths; }这段代码有三个设计点要解释。第一,数组长度是 n + 1,最后一格存整行累计宽度而不是某个字符的宽度,这样命中测试才能二分。第二,代理对的高代理项存整体宽度,低代理项存 0,遍历绘制时光标位置不会重复累加。第三,CJK 字符测出来的宽度取 1f 的下限,是因为某些字体对生僻字回退失败时会返回 0 宽度,0 宽度会让命中测试在二分时陷入错误区间,表现为点击后光标死活不动。
调用点很关键。常见做法是在 TextChanged 事件里只重建 ChangedRange 覆盖的行,而不是每敲一个字重建全文件;在 FontChanged 事件里才全量重建。如果不区分这两个场景,大文件输入会明显卡顿,这是新手最容易踩的性能坑。
3.3 命中测试换算:二分查找的落点修正
宽度表修对了,光标落点依然可能不准,因为 FastColoredTextBox 的命中测试逻辑里还有一个隐藏问题:它找到的是“左边界的字符”,没有对点击点在字符内部的左右位置做判定。英文窄字符无所谓,中文全角字宽,点击右半段时用户预期是落在下一个字符前,但旧逻辑会停在当前字符上。
修正后的逻辑是对二分结果再做一次“中点修正”:
// 修正后的像素 X 坐标到字符索引换算 // x 是相对于行首的像素偏移,lineIndex 是目标行号 public int CharIndexFromXY(int x, int lineIndex) { var line = Lines[lineIndex]; float[] w = line.CharWidths; // w[i] 是前 i 个字符的累计宽度 int len = line.Text.Length; // 二分查找:找第一个累计宽度 >= x 的字符 int lo = 0, hi = len; while (lo < hi) { int mid = (lo + hi) / 2; if (x < w[mid]) hi = mid; else lo = mid + 1; } // 中点修正:点击字符右半段时,索引后移一位 if (lo > 0 && lo < len) { float left = w[lo - 1]; float right = w[lo]; if (x - left > (right - left) / 2f) { lo = Math.Min(lo + 1, len); } } return lo; }这里的 w 数组是前缀和,w[mid] 表示第 mid 个字符之前的累计宽度,二分条件 x < w[mid] 找到的是覆盖点击 x 坐标的第一个字符。中点修正的数学含义是:点击点落入当前字符的左半段就留在当前索引,落入右半段就移到下一字符。边界条件同样重要:lo 为 0 时 left 取 0,防止数组越界;lo 等于 len 时不做修正,直接返回行尾索引,避免把行尾空白当成一个可点击的字符。
这个修正做完,光标的左右落点基本就和肉眼对齐了。但要注意,它只解决“点击定位”,不解决“键盘左右键移动”。键盘移动走的是字符索引增减,和宽度表无关,不需要额外处理。
3.4 中文输入法联动:候选框跟随光标
中文用户还有一个高频痛点:输入法候选框不跟随光标,跑到屏幕左上角或控件左上角。这个问题的根因在 ImeStartComposition 事件里用了控件早期的坐标换算,而那个换算同样受宽度表影响。修正思路是在事件里重新用修正后的换算把光标位置转成屏幕坐标:
// 输入法组合窗口位置修正 private void tb_ImeStartComposition(object sender, ImeStartCompositionEventArgs e) { int idx = tb.Selection.Start.iChar; int lineIdx = tb.Selection.Start.iLine; Point p = tb.PointToScreen(tb.PositionToPoint(new Place(idx, lineIdx))); // 用 p 作为候选窗口锚点,常见做法是调用系统 API 移动组合窗口 }这段代码里 Place 是行列位置结构体,PositionToPoint 把行列转成控件内像素坐标,PointToScreen 再转成屏幕坐标。关键点是不要在事件参数里直接取 e 给的旧位置,而要用修正后的 CharIndexFromXY 先算出正确的 iChar,再走一遍坐标换算。我见过不少修正版只改宽度表不改这个事件,中文输入时光标依然跳,就是这个原因。
4. 修复样式错位:Token 边界、行宽缓存与重绘时机
4.1 样式错位的三类表现,根因都指向同一个缓存
样式错位在中文场景下通常表现为三种形态。第一种是中文关键字或注释的背景色块比文字短一截或长一截,典型的“背景没盖住字”;第二种是波浪线或下划线样式中途断开,特别是在全角标点附近断;第三种是深色主题下当前行高亮整行向右偏移几个像素,文字和背景对不上。这三种现象的共同根因是:样式绘制循环在累加字符位置时读到的宽度表,和绘制文字时实际使用的宽度表不是同一份数据。比如文本变更后加了新行,新行的宽度表按新字体重建了,旧行的还是旧尺寸;或者字体变了但宽度表没跟着变。中西文混排时,几行的偏差叠加起来,视觉上就会看到背景和文字逐渐错开。
定位这类问题我一般会在样式绘制入口打一个断点,检查 style 要画的 Token 范围和当前行 CharWidths 的长度是否匹配。如果 Token 长度是 6,CharWidths 长度是 8,那这个 Token 的前 6 个字符必然被画在错误的 x 坐标上。把宽度表长度和 Token 边界对齐,是修复样式错位的第一步。
4.2 增量更新行宽缓存:不要每次全表重建
性能和生产效率在这里是对立的。全量重建宽度表在几千行的文件里会带来肉眼可见的卡顿,而完全不重建则样式错位会一直存在。常见的折中方案是增量更新:文本变更事件里只重建实际被修改的行。FastColoredTextBox 的 TextChangedEventArgs 提供了 ChangedRange,它精确描述了变更的行区间:
// 文本变更后,只重建变更行覆盖到的宽度表 private void tb_TextChanged(object sender, TextChangedEventArgs e) { for (int i = e.ChangedRange.Start.iLine; i <= e.ChangedRange.End.iLine; i++) { if (i < tb.Lines.Count) { RebuildLineWidths(tb.Lines[i], tb.Font); } } tb.Invalidate(); // 触发重绘,但不改变布局,避免闪烁 }这里有一个重要的边界判断:循环里要检查 i 是否小于 tb.Lines.Count。删除行时 ChangedRange 的 End.iLine 可能超过当前 Lines 数量,直接索引会越界。Invalidate 而不是 Refresh,是因为 Refresh 会强制同步重绘,在连续输入时反而造成闪烁;Invalidate 把重绘合并到 UI 线程的下一次消息循环里,性能好得多。
字体变化时则必须全量重建,因为字体影响每一个字符的宽度。常见做法是把字体变更处理放在应用按钮里,而不是字体下拉框的 SelectedIndexChanged 里,避免用户滑动下拉列表时反复触发全量重建:
// 字体切换后全量重建,只在用户确认应用时执行 private void tb_FontChanged(object sender, EventArgs e) { foreach (var line in tb.Lines) { RebuildLineWidths(line, tb.Font); } tb.Invalidate(); }全量重建千万不能在字体选择事件里做。一个字体下拉框里几十个字体,每选一个就重建一次上万行文本的宽度表,控件会卡到像是在调试死循环。
4.3 样式绘制时使用统一宽度源:逐字符绘制
宽度表重建好了,样式绘制本身也要改。FastColoredTextBox 的默认做法是用 Style.Draw 一次性绘制整个 Token 区域,然后让 Style 内部决定怎么画背景和装饰线。这种做法在纯英文场景下没问题,但遇到 CJK 字符时,Style 内部如果用了不同的测量方式,Token 位置就会在中间某个字符处开始偏移。
我一般会在自定义 Style 的绘制逻辑里改成逐字符遍历,把 x 累加完全建立在 CharWidths 上:
// 逐字符绘制样式:锁定宽度表与绘制同步 private void RenderStyleRun(Graphics g, Style style, Line line, int tokenStart, int tokenLength, int baseX, int lineY) { float x = baseX; float[] w = line.CharWidths; int h = tb.LineHeight; for (int i = tokenStart; i < tokenStart + tokenLength && i < w.Length - 1; i++) { float charW = w[i]; if (charW > 0f) { style.Draw(g, new Rectangle( (int)Math.Round(x), lineY, (int)Math.Ceiling(charW), h)); } x += charW; } }这段代码的关键参数是 Math.Round 和 Math.Ceiling 的配合。矩形左侧用 Round 取整,宽度用 Ceiling 向上取整,保证背景色块能完整覆盖字符的可见区域;如果都用 Round,宽度不足时字符右侧会露一条缝,这在深色主题下特别显眼。逐字符绘制比一次整段绘制慢,但实际体感差别不大——样式绘制只在重绘时执行,不是每帧执行。几千行文件里如果有性能余量,可以把背景类样式合并成连续矩形区域再画,下划线和波浪线必须逐字符,否则中文断线问题治不好。
5. 中文场景常见问题排查:现象、原因与解法
5.1 中文输入法候选框出现在屏幕左上角
现象:在编辑器里输入中文时候选框不跟随光标,固定在屏幕左上角或者控件外。原因:输入法组合窗口的位置来自 ImeStartComposition 事件参数里的旧坐标,而旧坐标由未修正的命中测试换算而来,中英文混排行尤其不准。解决:在事件处理器里重新用修正后的 CharIndexFromXY 和 PositionToPoint 计算光标屏幕坐标,再移动候选窗口。改完记得测试光标在行首、行中、行尾三种位置的候选框位置是否一致。
5.2 深色主题切换后注释高亮整体左移
现象:切换主题后,中文注释的背景色块整体比文字左移 2~3 像素,但英文代码正常。原因:主题切换通常会替换样式字体,字体变更却没有触发宽度表重建,样式绘制还在用老字体对应的宽度表。解决:在主题切换代码里显式调用全量宽度重建,而不是依赖 FontChanged 事件;如果主题系统直接改主字体,事件会被触发,但改的是样式字体时事件不会发生。这一类问题最隐蔽,排查时先看字体对象是否真的变了。
5.3 点击中文字符右半段总是选中后一个字符
现象:点击“编辑”的“编”字右半边,选中的却是“辑”字。原因:命中测试二分查找返回的是覆盖点击点的左边字符索引,没有做中点修正;中文全角字宽度大,右半段明显落入下一个字符的判定区。解决:在二分后加入“点击点相对当前字符宽度中点”的判断,落入右半段则索引加一。这个修复对所有东亚字符有效,包括全角标点。
5.4 高分屏 150% 缩放下滚动条与光标位置系统性漂移
现象:显示缩放设为 150% 时,水平滚动条拖到哪里,光标和点击落点就偏到哪里,放大倍数越大偏差越大。原因:控件内部按 96 DPI 逻辑坐标计算,但 TextRenderer 在系统缩放下返回的是物理像素宽度,两边不一致导致所有坐标换算系统性偏移。解决:为程序声明 PerMonitorV2 DPI 感知,在 DpiChanged 事件里按 DeviceDpi 缩放宽度表和行高数据。需要注意的是,光改 app.manifest 不够,必须在宽度表重建时把 DeviceDpi 纳入计算。
5.5 中英文混排行底部文字被裁剪
现象:微软雅黑 12pt 显示正常,但混入全角符号或生僻字后,字符上半部分被裁掉一截。原因:行高按主字体测量,而回退字体的字形高度更大,导致绘制区域不足。解决:行高取 TextRenderer.MeasureText 对主字体和回退字体的高度中的较大值。这个修复要在 OnPaint 外做,因为行高影响整个布局,临时改必然导致全部行错乱。
6. 验证技巧:用最小测试工程把三个修复点一次回归到位
修复做完不等于修完,要有一套能反复跑的回归清单。我一般把验证过的最小工程保留下来,作为后续所有改动的基准,每次改完宽度表、命中测试或绘制逻辑后,都按下面这张表逐项过一遍:
| 场景 | 操作 | 通过标准 |
|---|---|---|
| 中英混排 | 逐字点击每个中文字符左右半段 | 光标位置与肉眼预期一致,无偏移 |
| 中文注释高亮 | 切到深色主题并重建样式 | 背景色块完整覆盖文字,无左右缝隙 |
| 中文输入法 | 光标在不同行位置输入拼音 | 候选框跟随光标,无跳动和复位 |
| 高分屏缩放 | 系统缩放切到 150% 后拖动滚动条 | 滚动位置与点击落点一致 |
| 大文件冒烟 | 打开两万行中文日志文件 | 输入不卡顿,样式绘制无峰值延迟 |
除了手测,我还会跑一段自动自检代码,把每个字符的缓存宽度和实时测量宽度做一次全量比对,偏差超过 1.5 像素的字符视为异常:
// 自动化自检:抽样验证宽度表与实测一致性 private void VerifyWidths(FastColoredTextBox tb) { int bad = 0; int limit = Math.Min(tb.Lines.Count, 5000); for (int i = 0; i < limit; i++) { string text = tb.Lines[i].Text; for (int j = 0; j < text.Length; j++) { char c = text[j]; if (char.IsLowSurrogate(c)) continue; // 低代理项记 0,跳过 float cached = tb.Lines[i].CharWidths[j]; float real = MeasureCharWidth(c, tb.Font); if (Math.Abs(cached - real) > 1.5f) bad++; } } Debug.WriteLine($"宽度表偏差字符数: {bad}"); }阈值取 1.5f 而不是 1f,是为了容忍字体微调带来的四舍五入误差;自检的作用是抓异常,不是抓极致精度。抽样上限 5000 行是性能考虑,全量比对在两万行文件里会让调试过程明显变慢。
这里有一个我反复踩过的坑:验证中文输入法时,一定要把系统输入法切到中文模式再跑,不要用英文输入法做冒烟。ImeStartComposition 事件只在中文输入法启动组合窗口时触发,英文模式下走的是普通键盘消息路径,两者代码分支完全不同。我习惯先跑一遍场景 A 和场景 C,再去做任何性能调优——字体回退会有玄学成分,同一套代码在不同 Windows 版本上的回退字体不同,只有实测才能确认当前环境下的真实表现。最后提醒一句:V2 这类修正版解决的是度量问题,但你自己的业务代码如果绕过 CharWidths 直接调 GDI 画文字,照样会错位。记住这个边界,改动会少很多,希望帮到你。
本文还有配套的精品资源,点击获取