编写一个Open Live Writer的VSCode代码插件
起因
又是一年多没有更新过博客了,最近用Arduino做了一点有意思的东西,准备写一篇博客。打开尘封许久的博客园,发现因为Windows Live Writer停止更新,博客园推荐的客户端变为了Open Live Writer(基于Windows Live Writer代码,然而GitHub上的代码已经快一年没更新了)。OK,安装之后开始写作,写着写着就发现问题了:在准备插入代码的时候发现没有对应的Open Live Writer代码插件。
两个编辑器
在日常编辑Arduino代码时我会用到两个编辑器,Arduino IDE和Visual Studio Code(以下简称VSCode)。
Arduino IDE语法高亮效果如下:
VSCode中语法高亮效果如下:
可以看到,Arduino IDE 语法高亮明显不如VSCode的丰富。更重要的是,Arduino IDE没有代码智能提示。这年头,写代码没有智能提示就少了半条命。所以,在需要大量写Arduino代码的时候,我都是使用VSCode完成。附带说一句,VSCode支持Arduino可以参考这篇文章:Enabling Arduino Intellisense with Visual Studio Code)。
Arduino IDE 复制成HTML格式
在Arduino IDE中有一个功能叫做“复制为HTML格式”,上述语法高亮函数签名复制出来的HTML代码如下所示:
<pre>
<font color="#00979c">void</font> <font color="#000000">Matrix4</font><font color="#434f54">:</font><font color="#434f54">:</font><font color="#d35400">writeSprite</font><font color="#000000">(</font><font color="#00979c">int</font> <font color="#000000">startColumn</font><font color="#434f54">,</font> <font color="#00979c">int</font> <font color="#000000">startRow</font><font color="#434f54">,</font> <font color="#00979c">const</font> <font color="#00979c">byte</font> <font color="#434f54">*</font><font color="#000000">sprite</font><font color="#000000">)</font>
</pre>
可以看到,其中使用了不被推荐的font标签。
如果喜欢Arduino IDE的语法高亮的话,在写博客的时候可以将Open Live Writer切换到Source模式,并将要复制的HTML代码粘贴到要插入的位置。但是这样要麻烦一点,会在Edit模式和Source模式之间切换。
VSCode代码编辑插件
然而在网上并没有找到VSCode对应的代码编辑插件,于是将要分享的东西放在一旁,开始了折腾。
尝试方案一:VSPaste
一见VSCode,立刻想到VS,立刻想到VSPaste(Windows Live Writer上插入VS代码的插件)。上面那句话是模仿鲁迅的,原话是:一见短袖子,立刻想到白臂膊,立刻想到全裸体。。。
VSCode和VS都是微软的,我就想VSPaste应该也支持VSCode吧。VSPaste我熟啊,当初还专门写了一篇文章:一次查找Windows Live Writer的VSPaste插件丢失RTF格式信息的经历。
第一次尝试失败
于是找到Open Live Writer的安装目录。寻找安装目录有多种方式,包括但不限于:利用任务管理器查找,利用Everything查找,通过开始菜单快捷方式查找。我轻车熟路的在安装目录下面新建一个Plugins目录,将VSPaste.dll复制过去。然后信心满满的重启Open Live Writer。纳尼,啥也没有!
第二次尝试失败
博客园推荐的,应该有对应的官方插件,我去找找看官方对应的Windows Live Writer和Open Live Writer有什么区别。发现一篇文章:OpenLiveWriter代码插件 。
通过对文章的阅读,明白了不生效的原因在于接口不匹配。那么这个问题就好解决了,反编译VSPaste的源代码,新建工程,将接口改成新的接口。
就在我准备自己动手的时候,无意在GitHub上发现了一个项目:LiveWriter.VSPaste,该项目将VSPaste移植到了Open Live Writer(下文简称OLW,实在是懒得打字了)。
于是兴冲冲的下载了对应的文件,将其复制到Plugins目录,然后信心满满的重启OLW。然而,这次还是啥都没有。
第三次尝试失败
为了一探上次加载插件失败的原因,我下载了LiveWriter.VSPaste的源码,将生成的文件复制过去,奇怪的是无论是Debug还是Release,除了显示找不到图标之外,插件能够正确加载。
更神奇的是,将下载的文件用ILSpy进行反编译,和源代码比较,没有发现明显差异。这可真是哔了狗了,同时也勾起了我的好奇心。于是我下载了OLW的源码,进行编译生成,这次倒是比较顺利,无风无浪,一气呵成。
设置好启动项目,找到生成目录,将下载的文件复制到Plugins目录下,正式开始查找原因。在解决方案管理器中查找Plugin,发现有个PluginLoader文件,发现里面有个叫LoadPluginsFromDirectory的函数,直觉告诉我应该就是它。打上断点,开始调试。果然,断点中断了,经过单步调试一步步的到了案发现场:一个名叫LoadFromWithRetry的函数。
修改LoadFromWithRetry函数的代码,获取异常详情,得到了以下异常信息:An attempt was made to load an assembly from a network location which would have caused the assembly to be sandboxed in previous versions of the .NET Framework. This release of the .NET Framework does not enable CAS policy by default, so this load may be dangerous. If this load is not intended to sandbox the assembly, please enable the loadFromRemoteSources switch. See http://go.microsoft.com/fwlink/?LinkId=155569 for more information.
看样子是从网络加载程序集,可是也不对啊,下载的文件我用ILSpy看过啊,没有从网络下载啊。真是头大,问题一时陷入了僵局。
第四次尝试失败
就在我一筹莫展的时候,无意中我在点开下载文件的属性中发现了这么一个东西:
勾选解除锁定,点击确定,再次启动调试,果然没有在LoadFromWithRetry函数的异常处理处中断。真是山重水复疑无踪,柳暗花明又一村。
还没等我好好高兴,又跳出来一个报错对话框:
接下来又是前面缺少图标的报错。定位到刚刚报错的行:
通过分析代码中Bitmap的构造:
我们可以确定,加载图片的路径是程序集名称加上导出设置中的ImagePath。通过ILSpy反编译生成的文件,查看程序集信息,可以发现其中并没有任何资源。
打开工程文件,查看对应图片的属性,发现其生成操作为无,将其改为嵌入的资源。
重新生成、复制文件、开始调试,一切顺利完成,正确显示了插件。
终究是无用
再次信心满满,打开VSCode,复制代码,点击OLW中的VSPaste。然而,视图中还是啥都没有。
经过之前上次的折腾(一次查找Windows Live Writer的VSPaste插件丢失RTF格式信息的经历),我的第一反应就是判断剪贴板中是否含有RTF格式的数据,因为VSPaste的原理是判断剪贴板中是否存在RTF格式的数据,如果存在,将其转换为HTML。
新建一个WinForm工程,使用Clipboard.ContainsData(DataFormats.Rtf) 判断剪贴板中是否含有RTF格式数据。嗯,果然没有,此路不通。罢了罢了。
尝试方案二:寻找已有插件
寻找资料
在搜索引擎中搜索VSCode Open Live Writer,没有发现现有的插件。想到Arduino IDE都有复制成HTML的功能,在网上搜搜看有没有VSCode复制成HTML的资料,于是发现了这篇文章:Copy As HTML From VSCode。
设置VSCode
在复制代码之间,需要设置VSCode,具体来讲的话就是打开编辑器的Copy With Syntax Highlighting功能,具体可参考Copy As HTML From VSCode。
学习借鉴
经过阅读Copy As HTML From VSCode中的代码,基本理解了代码的思路。大佬写的功能比较全面,包含行号显示,还有一些如文件名、展开折叠等的可选项。可选项如下:
在使用过程中,也发现了一个小bug。具体来讲,就是VSCode的代码中存在换行符时,生成的html代码中会出现br后接着div的情况,导致空行前后的行会粘连在一起,进而导致代码比行号更高。
艰难的决定
这个是基于html和js的,我不想每次插入代码都经过浏览器打开网页进行处理,再复制回OLW的源代码中。而且功能丰富,我也用不了那么多。所以最终决定,还是自己写一个VSCode代码插件,实现在OLW中点击就可插入的功能。
尝试方案三:自己编写插件
新建项目
说动手就动手,第一步当然是新建项目。如下:
新建一个类库项目,命名为VSCodePaste,并在其中添加OpenLiveWriter.Api的引用。
OpenLiveWriter.Api.dll位于OLW安装目录下
添加一个VSCodePaste类,并设置好相应的特性。
除了GUID和PublisherUrl外,其他元数据我都是从LiveWriter.VSPaste中修改的。
[InsertableContentSource("Paste from Visual Studio Code", SidebarText = "from Visual Studio Code"), WriterPlugin("{590ea9a7-b922-4de6-a712-b0ce6499cebd}", "VSCodePaste", Description = "Easily transfer syntax highlighted source code from Visual Studio Code to elegant HTML in Open Live Writer.", PublisherUrl = "https://www.cnblogs.com/yiyan127", ImagePath = "icon.png")]
将VSCodePaste继承自ContentSource,并重写CreateContent方法
public override DialogResult CreateContent(IWin32Window dialogOwner, ref string newContent) { return DialogResult.Cancel; }
此时仅仅是返回一个值。
将要作为图标的文件复制进入项目中,并在属性页中将生成的操作设置成嵌入的资源。
我是利用everything在VSCode的安装目录下的图标中寻找的
第一次生成错误
根据错误详细信息可以得知,是项目Framework版本号低于OpenLiveWriter.Api.dll的Framework版本号,将项目版本号改成4.6.1即可(更高也行)。
图标又找不到
图标的名称是VSCodePaste.icon.png,这个名称是参考LiveWriter.VSPaste的VSPaste.icon.png。但是在将生成文件复制到OLW的Plugin目录下后,重新打开OLW,又在报插件图标找不到。
打开ILSpy,查看程序集,发现在资源中图标名为VSCodePaste.VSCodePaste.icon.png,而在LiveWriter.VSPaste中,图标名为VSCode.icon.png。真是奇怪,属性中的设置都完全一样。那么问题出在哪里呢,一定有某个地方不一样。
在网上查询内嵌的资源,查不到什么干货。换成EmbeddedResource使得Bing搜索,发现有篇文章讲得比较清楚:Understanding Embedded Resources in Visual Studio .NET,引用原文如下:
查看LiveWriter.VSPaste的默认命名空间,果然为空。于是原因就知道了:
- 项目中文件名称为VSCodePaste.icon.png
- VS在生成时,自动在文件名称前加上默认的命名空间VSCodePaste,于是在生成的程序集中资源名称变成了VSCodePaste.VSCodePaste.icon.png
- Bitmap构造函数查找的是VSCodePaste.icon.png,而程序集中是不存在对应名称的资源的,问题就发生了。
于是解决方案也就有了,要么将默认命名空间改为空,要么将文件改为icon.png。我当然是选第二个。
在VS中,当默认命名空间不为空时,是不能将默认命名空间修改为空,除非卸载项目直接改工程文件。猜测LiveWriter.VSPaste中将默认命名空间设为空是为了链接文件,避免复制图标。
HTML不一致
继续进行编写插件,很快就发现了第一个问题:在工程中使用Clipboard.GetData(DataFormats.Html)获取的Html和Copy As HTML From VSCode在js中的onpaste事件获取的Html不一致。
在js中获取的Html如下所示,可以很明显的看出来是一个Html文档:
在WinForm工程中获取的Html如下,可以看到,除了Html文档以外,在前面还多了一些奇奇怪怪的字符:
从图中看,感觉前面一组:分隔的键值对挺像是对Html的描述。于是查找相关资料以确认,找到了HTML Clipboard Format验证了猜想。还找到了Add HTML code to the clipboard by using Visual C++,其中说明了使用VC++设置Html代码到剪贴板中。所以猜测,是浏览器作了相应的处理,将描述给去掉了。
提取HTML片段
既然浏览器可以处理,那我们自己也可以处理,而且可以处理的更彻底一点,直接提取出Fragment中的内容。因为有意义的只有Fragment中的内容,其外层总是body和html。
从HTML Clipboard Format中可以得出,只要我们获取到StartFragment和EndFragment的偏移,直接求子串就可以了。代码如下:
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 startIndex = -1; int endIndex = -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") { startIndex = int.Parse(value); continue; } if (key == "EndFragment") { endIndex = int.Parse(value); continue; } } if (startIndex == -1 || endIndex == -1) { return string.Empty; } return html.Substring(startIndex, endIndex - startIndex); }
代码很简单,利用正则表达式提取出描述部分,再查找片段的开始和结束,然后提取子串。
2020-10-08更新,上述代码存在问题,请直接使用后面的代码链接下载最新代码,详情请移步再议HTML Clipboard Format。
就此止步还是进一步前进
提取出子串了,貌似也能凑和使用了,毕竟保持了基本的样式。然而在VSPaste及博客园生成的代码中,是包含在pre标签中的。我想了想,还是保持一致吧,把代码放在pre标签中,中间用span来标明样式。
接下来的问题,是直接将片段代码转换成pre样式么?想了想,还是决定加一个中间层,原因如下:
- 直接转换代码不可避免的会保存层次信息,因为HTML片段本身就是一个层次结构
- 将数据和表现分离开,代码片段和pre标签都可以视为同样数据的不同表现。而且pre标签的表现方式也可能变化。
中间层就选用XElement,因为HTML也可以视作一种不规范的XML。
转换成XElement
转换的要点就在于识别出HTML元素(标签)的结构,即属性、内部文本及何时开始、何时结束。好在我们是针对VSCode代码的这种专有结构。所以逻辑可以写得比较简单。
层次结构处理
层次结构可以使用Stack(堆栈)来表示。其处理包括以下情况:
- 标签开始:标签元素添加至栈顶元素并入栈
- 标签结束前:将内部文本添加至栈顶元素
- 标签结束:标签元素出栈
- 标签开始即结束(特殊标签,如br):标签元素添加至栈顶元素(无需出入栈)
层次结构的处理代码如下:
private void BeginTag(Stack<XElement> tagStack, string tagInfo) { var matches = TagStyleRegex.Matches(tagInfo); Contract.Assert(matches.Count == 1); var tagName = matches[0].Groups["tagName"].Value; var style = matches[0].Groups["style"].Value; //while no style,the value is string.Empty; var tagElement = new XElement(tagName, new XAttribute("style", style)); if (tagStack.Count == 0) { _rootElement = tagElement; } else { tagStack.Peek().Add(tagElement); } tagStack.Push(tagElement); } private void AppendTag(Stack<XElement> tagStack, string tagInfo) { var tagElement = new XElement(tagInfo); tagStack.Peek().Add(tagElement); } private void EndTag(Stack<XElement> tagStack, string tagInfo) { var tagElement = tagStack.Peek(); Contract.Assert(tagElement.Name == tagInfo); tagStack.Pop(); } private void AppendTagText(Stack<XElement> tagStack, string text) { var tagElement = tagStack.Peek(); if (!string.IsNullOrEmpty(text)) { tagElement.Add(new XText(text)); } }
其中使用的正则表达式如下:
private static readonly Regex TagStyleRegex = new Regex(@"^(?<tagName>[a-zA-Z]+)(\s+style=""(?<style>.+)"")?");
正则表达式中的style部分是可选的,当没有style时,通过Groups["style"].Value获取到的值为空。
对应的文本处理
- 遇到字符<:标识着标签块的开始或即将结束。如下一个字符是/,则标识着标签即将结束,否则标志着标签的开始。如果标签即将结束,需要将缓冲区中的内部文本添加至栈顶元素。
- 遇到字符>:标识着标签块的声明完成(即标签开始)或正式结束,需要配合遇到字符<时的情况做处理。对应着标签开始、标签结束、标签开始即结束这三种情况。
- 将字符添加到缓冲区
文本处理的代码如下:
private void ParseFragment(TextReader reader) { bool isTagEnd = false; Stack<XElement> tagStack = new Stack<XElement>(); StringBuilder sb = new StringBuilder(); int num = reader.Read(); while (num != -1) { char c = (char)num; switch (c) { case '<': { num = reader.Read(); Contract.Assert(num != -1); c = (char)num; if (c == '/') { AppendTagText(tagStack, sb.ToString()); sb.Clear(); isTagEnd = true; } else { sb.Append(c); } break; } case '>': { if (isTagEnd) { EndTag(tagStack, sb.ToString()); sb.Clear(); } else { var tagInfo = sb.ToString(); if (tagInfo == "br") { AppendTag(tagStack, tagInfo); } else { BeginTag(tagStack, tagInfo); } sb.Clear(); } isTagEnd = false; break; } default: sb.Append(c); break; } num = reader.Read(); } }
2020-10-08更新,上述代码存在略微不足,即在ParseFragment结尾没有检查缓冲区是否为空,详情请移步再议HTML Clipboard Format。
将XElement转换成pre
三层结构
这部分主要就是将XElement的层次结构生成代码,在VSCode的代码中包括三层:
- 第一层的div是全局样式,包括背景和字体设置。
- 第二层的div和br,代表第一行
- 第三层的span,代表行中的一部分,即存在相同颜色的文本
代码如下:
public static string Convert(XElement rootElement) { using (var writer = new StringWriter()) { writer.Write(string.Format("<pre class=\"code\" {0}>", rootElement.Attribute("style"))); ConvertFragment(rootElement, writer); writer.Write("</pre>"); return writer.ToString(); } } private static void ConvertFragment(XElement rootElement, TextWriter writer) { foreach (var lineElement in rootElement.Elements()) //discard the root div which contains style { ConvertLine(lineElement, writer); writer.Write(Environment.NewLine); } } private static void ConvertLine(XElement lineElement, TextWriter writer) { foreach (var partElement in lineElement.Elements()) { if (partElement.Name == "span") { ConvertSpan(partElement, writer); } } }
span的处理
值得一提的是针对span的处理,根据包含的空格数量,可以分为三种:
- 不包含空格:原样添加。
- 全为空格:添加同样数量的空格。
- 部分为空格:添加文本前空格、在span部分中去除空格并添加到span、添加文本后空格。
代码如下:
private static void ConvertSpan(XElement spanElement, TextWriter writer) { var value = spanElement.Value; var replaced = value.Replace(" ", " "); int startSpaceCount = 0; for (int i = 0; i < replaced.Length; i++) { if (replaced[i] == ' ') { writer.Write(' '); //write startSpaceCount++; } else { break; } } //  length is 6 and space length is 1,if one is replaced,length will minus 5 int endSpaceCount = (value.Length - replaced.Length) / 5 - startSpaceCount; if (startSpaceCount != replaced.Length) //there will be other text { writer.Write(string.Format("<span {0}>{1}</span>", spanElement.Attribute("style"), replaced.Trim())); for (int i = 0; i < endSpaceCount; i++) { writer.Write(" "); } } }
在代码中,将第一种和第三种情况合为一种进行处理了,即startSpaceCount != replaced.Length之后的代码。先添加去除空格的文本,再添加文本后空格(不包含空格时不会添加)。
其中计算结束空格字符数量使用了一个技巧:结束空格字符数量=(替换前字符长度-替换后字符数量)/ 5-开始空格字符数量。其原理如下:
- 结束空格字符数量=总的空格数量-开始空格字符数量。
- 总的空格数量=(替换前字符长度-替换后字符数量)/ 5。即每替换一个空格,其长度从6( 的长度)变成了1( 的长度),总共减少了5个字符。
样式示范
void Matrix4::writeSprite(int startColumn, int startRow, const byte *sprite) { int columnCount = sprite[0]; int rowCount = sprite[1]; if (rowCount == 8 && startRow == 0) { for (int i = 0; i < columnCount; i++) { int c = startColumn + i; if (c >= 0 && c < 80) setColumn(c, sprite[i + 2]); } } else { for (int i = 0; i < columnCount; i++) for (int j = 0; j < rowCount; j++) { int c = startColumn + i; int r = startRow + j; if (c >= 0 && c < 80 && r >= 0 && r < 8) setDot(c, r, bitRead(sprite[i + 2], j)); } } }
完整代码
博客园:VSCodePaste
彩蛋
VSCode的Copy With Syntax Highlighting开关
通过比较两个图可以得知,启用开关之后剪贴板中支持的格式多了HTML
VS的Copy As Html
在写博客插入VS代码时,随便研究了一下VS复制的Html剪贴板格式,可以发现,和我们生成的样式很接近。
所以,其实也可以不用VSPaste,自己写一个VSHtmlPaste,事实上,我也写了一个,感兴趣的请参考前面的代码链接。在写的时候出现了一个问题,就是VS产生的Html中的EndHTML、EndFragment、EndSelection比实际的多了6。这个问题就不在这里追究了,不然真成了老太太的裹脚布。
2020-10-08更新,对原因感兴趣的朋友请移步再议HTML Clipboard Format。
补充一下:VS的Copy As Html功能需要安装插件,我的是VS2019,安装了Productivity Power Tools 2017/2019。
参考链接
Enabling Arduino Intellisense with Visual Studio Code
一次查找Windows Live Writer的VSPaste插件丢失RTF格式信息的经历
GitHub - OpenLiveWriter/OpenLiveWriter: An open source fork of Windows Live Writer