起因
在写作编写一个Open Live Writer的VSCode代码插件的彩蛋部分时,写的VSHtmlPaste一直有问题。具体来说,就是VSHtmlPaste产生的Html中的EndHTML、EndFragment、EndSelection比实际的多了6。具体表现就是在复制的代码后面有<!--En这些字符。比如复制pubic,效果如下:
那篇文章已经够长了,就另起了这篇来探讨这个问题。
背景
在VSHtmlPaste中,所复制的Html是由Html是由Productivity Power Tools 2017/2019产生的,通过一个HtmlFragmentExtractor的类提取Html片段。
该类的主要作用是从符合HTML Clipboard Format格式中提取代码片段。该类代码如下:
private static readonly Regex DescriptionRegex = new Regex(@"^([a-zA-Z]+:[a-zA-Z0-9\.]+\r\n)+"); internal static string Extract(string html) { var matches = DescriptionRegex.Matches(html); if (matches.Count == 0) { return string.Empty; } int start = -1; int end = -1; var descriptions = matches[0].Value.Split(new string[] { "\r\n" }, StringSplitOptions.RemoveEmptyEntries); foreach (var description in descriptions) { var pairs = description.Split(':'); var key = pairs[0]; var value = pairs[1]; if (key == "StartFragment") { start = int.Parse(value); continue; } if (key == "EndFragment") { end = int.Parse(value); continue; } } if (start == -1 || end == -1) { return string.Empty; } return html.Substring(start, end - start); }代码很简单,利用正则表达式提取出描述部分,再查找片段的开始和结束,然后提取子串。而这个类的代码已经在VSCodePaste中经过验证,可以正常使用。
查找原因
既然VS的Html是由Productivity Power Tools 2017/2019产生的,那么直接去查看对应的源码好了。下载好了源码,打开工程,定位到CopyAsHtml项目,打开第一个文件:ClipboardSupport,略微一看,发现正是要找的代码。
从图中可以看出,计算EndFragment使用的是字节数,而并不是字符数。
再次前往HTML Clipboard Format,仔细阅读,果然,描述使用的是字节数。而且明确说明了只支持UTF8,在上下文中可以使用其他字符。
造成这个问题的主要原因在于我在C#的世界呆久了,早已忘了当年的MFC了。在阅读Add HTML code to the clipboard by using Visual C++时全是char,就想当然的对应上了C#的char。全然忘了C++的char是1个字节,wchar_t 才是2个字节,而C#的char是2个字节。而在C#中的char代表的只是码点(codepoint),具体一个字符占多少个字节,则是由对应的编码确定。由于HTML Clipboard Format只支持UTF-8,所以占多少个字节是由UTF-8编码确定。
另外一个原因则是使用英文习惯了。在阅读英文资料的时候没有中文的示例,在编写OLW的VSCode代码插件时使用的示例代码中也没有中文,所以没有发现问题。
验证
打开VS,随便复制出一段代码,查看对应的HTML格式数据。
仔细一看,新宋体3个字很是特别。而这个新宋体是VS中的默认字体。所以一复制,样式中就出现了新宋体。而在UTF-8中中文占3个字节。使用GetByteCount函数计算一下对应的字节数。嗯,是9,比起字符数3,确实多了个6。
再次验证
在VSCode中顺便找一行代码中添加注释,注释内容为一大串中文字符,再次复制并通过代码插件插入OLW,果然,报错了。
修改方案
既然问题原因已经找到了,接下来的问题就是修复了。
第一次尝试
修复我的第一反应就是去Encoding类中查看是否有获取字符字节数的重载,然而并没有,只有获取字符数组和字符指针的字节数的重载。
这样也不是不行,可以把String转换成字符数组,然后使用第一个重载,利用二分查找的原理进行统计。代码如下:
internal static string Extract(string html, int fragmentByteStart, int fragmentByteEnd) { int startIndex = fragmentByteStart;//before start usually is ascii int endIndex = Math.Min(fragmentByteEnd - 1, html.Length - 1);//in case of index out of range int target = fragmentByteEnd - fragmentByteStart; char[] array = html.ToCharArray(startIndex, endIndex + 1 - startIndex); int low = 0; int high = array.Length - 1; int middle; while (true) { middle = (low + high) / 2; int byteCount = Encoding.UTF8.GetByteCount(array, 0, middle + 1); if (byteCount == target) { break; } if (byteCount < target) { low = middle + 1; continue; } if (byteCount > target) { high = middle - 1; } } return html.Substring(startIndex, middle + 1); }
只是这样的代码终究不是那么直观,暂时观察,留作后备方案。
第二次尝试
既然没有获取字符字节数的重载,那不妨看看获取字符串字节数的实现。打开Reference Source,找到UTF8Encoding对应的代码。
这么一看,更是复杂,还涉及到Surrogate等概念,毕竟要做到通用,就会复杂一些。但是我们用不到那么多的功能,此方案暂时搁置。
关于编码更多知识可以查看知乎专栏:刨根究底学编程
第三次尝试
Char结构体中是否有获取字节数的函数,虽然基本上不可能,但是万一呢。查看定义,并没有,倒是有一堆Surrogate的函数。
解决方案
既然没有现成的,那就自己动手写一个获取字节数的函数。根据UTF-8编码方案,可以很容易写出代码:
internal static int GetUtf8ByteCount(char c) { int codePoint = c; if (codePoint <= 0x7f)//ascii { return 1; } else if (codePoint <= 0x7ff) { return 2; } else if (codePoint <= 0xffff) { return 3; } else //will not reach,because 0xffff is char.MaxValue { return 4; //Supplementary Multilingual Plane,辅助平面 } }
既然有了函数,剩下的代码就好写了,如下:
internal static string Extract(string html, int fragmentByteStart, int fragmentByteEnd) { int startIndex = fragmentByteStart;//before start usually is ascii int endIndex = -1; int current = fragmentByteStart; for (int i = fragmentByteStart; i < html.Length; i++) { current += GetUtf8ByteCount(html[i]); if (current == fragmentByteEnd) { endIndex = i; break; } } Contract.Assert(endIndex != -1); return html.Substring(startIndex, endIndex + 1 - startIndex); }
经过验证,该方案测试通过。
另一个解决方案
除了上面的方法,还另有一种简单的办法。直接查找<!--StartFragment-->和<!--EndFragment--> 出现的位置,都不用解析描述。
但是<!--StartFragment-->和<!--EndFragment—>貌似不是硬性要求,不过VS和VSCode产生的HTML都采用了该方式,所以在当前场景下也算可用。代码太简单,就不贴上来了。
彩蛋
样式不一致?
在验证一节中,有心细的朋友可能会发现,VS中设置的字体大小是10,但是在复制生成的HTML代码中,font-size却是13px,是不是又有Bug了?
其实这是因为单位不同而导致的,VS设置中的字体大小单位是Point(点、磅、pt),而font-size的单位是pixel(像素、px)。
Point的历史就是孩子没娘,说来话长了,感兴趣的可以通过字号 (印刷)和点 (印刷)来了解。
对于pt和px,只需要记住1pt=1.33px就行了。10*1.33约等于13,这也是font-size为13px的原因。至于原因,是因为72pt=1英寸,而96px=1英寸。更多不同之处可以通过Difference Between Pixel (Px) and Point (Pt) Font Sizes in Email Signatures了解。
汉字数量少VSCodePaste一切正常
在使用VSCodePaste验证时,我发现在代码段中只有一两个汉字注释时,却不会报错。这又是为什么呢?
打开HtmlFragmentParser代码阅读,发现原因在于只有遇到</和>才会处理缓冲区中的文本。而当只有一两个汉字注释时<!--EndFragment-->已经处理了<,却还没有处理到>。因此当文本中汉字少于8个时,都不会报错(<!--EndFragment-->长度为18,而每多1个汉字时,<!--EndFragment-->就会多2个字符被处理。而当18个字符全被处理时,就会处理缓冲区处理,导致校验不通过。所以最多只能多18/2-1个汉字)。
参考
编写一个Open Live Writer的VSCode代码插件
Add HTML code to the clipboard by using Visual C++
UTF-8, a transformation format of ISO 10646
Difference Between Pixel (Px) and Point (Pt) Font Sizes in Email Signatures
Difference Between Pixel (Px) and Point (Pt) Font Sizes in Email Signatures