1. 程式人生 > >編寫一個Open Live Writer的VSCode程式碼外掛

編寫一個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);
        }

程式碼很簡單,利用正則表示式提取出描述部分,再查詢片段的開始和結束,然後提取子串。

就此止步還是進一步前進

提取出子串了,貌似也能湊和使用了,畢竟保持了基本的樣式。然而在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();
            }
        }

將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("&#160;", " ");

            int startSpaceCount = 0;
            for (int i = 0; i < replaced.Length; i++)
            {
                if (replaced[i] == ' ')
                {
                    writer.Write(' '); //write 
                    startSpaceCount++;
                }
                else
                {
                    break;
                }
            }

            //&#160; 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 == 0 && endSpaceCount == 0)
            {
                writer.Write(spanElement.ToString());
            }
            else
            {
                if (startSpaceCount != replaced.Length) //there will be other text
                {
                    writer.Write(string.Format("<span style=\"{0}\">{1}</span>",
                        spanElement.Attribute("style"),
                        replaced.Trim()));
                    for (int i = 0; i < endSpaceCount; i++)
                    {
                        writer.Write(" ");
                    }
                }
            }
        }

其中計算結束空格字元數量使用了一個技巧:結束空格字元數量=(替換前字元長度-替換後字元數量)/ 5-開始空格字元數量。其原理如下:

  • 結束空格字元數量=總的空格數量-開始空格字元數量。
  • 總的空格數量=(替換前字元長度-替換後字元數量)/ 5。即每替換一個空格,其長度從6(&#160的長度)變成了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。這個問題就不在這裡追究了,不然真成了老太太的裹腳布。

補充一下:VS的Copy As Html功能需要安裝外掛,我的是VS2019,安裝了Productivity Power Tools 2017/2019。

參考連結

Enabling Arduino Intellisense with Visual Studio Code

一次查詢Windows Live Writer的VSPaste外掛丟失RTF格式資訊的經歷

OpenLiveWriter程式碼外掛

LiveWriter.VSPaste

GitHub - OpenLiveWriter/OpenLiveWriter: An open source fork of Windows Live Writer

Copy As HTML From VSCode

HTML Clipboard Format

Add HTML code to the clipboard by using Visual C++

Understanding Embedded Resources in Visual Studio