本鱼拟成立工作室承接项目开发/软件定制/云设施开发运维/办公设备技术支持等,如您有相关需求,欢迎来询 | ::博客文章推荐::

CopyAsHtml 插件(Visual Studio 2010 Pro Power Tools组件之一) 的修正和增强

: 开发工具 木魚 4370℃ 4评论

这里说到的Copy As Html是在 Visual Studio 2010 Pro Power Tools 中集成的一个组件,功能是在Visual
Studio的文本编辑器中按下Ctrl+C或通过其它方式执行“复制”功能的时候,生成一份HTML格式的源码放到剪贴板中,此时可以在网页编辑器、网页中的可视化编辑器中直接粘贴出和VS中格式相同的源码。

当然这样的插件古已有之,在VS2008时代就有PasteAsHtml插件,但是那个插件不支持中文。在Windows Live
Writer中也有不少类似插件,也有支持中文且比较美观的插件,更有利用客户端脚本进行加色的插件。但是相比VS中原生支持的功能来说,还是略显欠缺。因为除去语法加色外还有上下文环境的加色,比如类名等等,当脱离VS的环境时,已经无法分析这些语言结构,加色也就无从谈起。

好的,优势说完。这里的CopyAsHtml插件有点小毛病。比如如下图所示的源码(这是正常情况下粘贴的图像):

图片

复制到网页的时,会发现复制不完整,总会丢掉点什么东西。丢掉的内容多少与选中区域的非英文字符数有关系,非英文字符越多,丢掉的越多:

图片

除此之外,还有个不是毛病的毛病,和其它所有源码加色脚本一样,它保留了完整的源码结构,包括缩进——这里说的缩进是块缩进,比如我复制的是一个方法,这个方法块在VS中是缩进2个Tab的,复制之后这2个Tab也完整的保留了下来。在大部分情况下,我们希望这两个所有行都具有的额外块缩进隐藏起来。通常情况下,我们只有在复制前按Shift+Tab取消缩进,复制完成后再恢复。

这里,将会尝试着修正复制的BUG,并加上取消这种公共缩进的功能。本文中所有源码均使用修正后的CopyAsHtml发布,修正后的源码和DLL文件请在文末下载。

 

修正复制丢失

从复制代码中出现非英文字符就会丢失来看,应该是编码导致的问题。CopyAsHtml插件位于VS2010的Extensions目录(路径在『%localappdata%MicrosoftVisualStudio10.0ExtensionsMicrosoftVisual
Studio 2010 Pro Power
Tools10.0.10602.2200』),典型的.Net程序集并且没有强名,用Reflector轻松反编译。以下代码在此反编译结果的基础上修改。

这里有个问题需要问下大伙儿,就是这个扩展是怎么调试的——修改过程不是很头疼,倒是为调试程序集烦恼不少。没找到实时调试的方法,我用的是写日志的方式调试的。

CopyAsHtml的工作方式,就是拦截VS的命令调用,检测到是复制命令时,就获得当前选区的源码结构,然后转换为HTML格式,最终放到剪贴板中。这个命令在
Microsoft.VisualStudio.Text.Formatting.Implementation.CommandFilter 中获得执行。

Microsoft.VisualStudio.Text.Formatting.Implementation.CommandFilter 下有很多类,其中
HtmlBuilderServiceProvider 负责转换源码为HTML结构,ClipboardSupport
则负责将转换后的结果添加到剪贴板中。跟踪发现,直到生成的HTML放到剪贴板前,所得到的HTML结构都是完整的,这意味着将HTML放到剪贴板的时候出现了点儿差错。

将HTML代码转换为剪贴板格式的代码如下(位于
Microsoft.VisualStudio.Text.Formatting.Implementation.ClipboardSupport 中):

private static string GetHtmlForClipboard(string htmlFragment)
{
    StringBuilder builder = new StringBuilder();
    string str = "Version:1.0rnStartHTML:<<<<<<<1rnEndHTML:<<<<<<<2rnStartFragment:<<<<<<<3rnEndFragment:<<<<<<<4rnStartSelection:<<<<<<<3rnEndSelection:<<<<<<<4rn";
    string str2 = "<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.0 Transitional//EN">rn<HTML><HEAD><TITLE>Snippet</TITLE></HEAD><BODY><!--StartFragment-->";
    string str3 = "<!--EndFragment--></BODY></HTML>";
    builder.Append(str);
    int length = builder.Length;
    builder.Append(str2);
    int x = builder.Length;
    builder.Append(htmlFragment);
    int num3 = builder.Length;
    builder.Append(str3);
    int num4 = builder.Length;
    builder.Replace("<<<<<<<1", To8DigitString(length));
    builder.Replace("<<<<<<<2", To8DigitString(num4));
    builder.Replace("<<<<<<<3", To8DigitString(x));
    builder.Replace("<<<<<<<4", To8DigitString(num3));
    return builder.ToString();
}

嗯……说到这里也许应该简单解释一下HTML代码是如何放在剪贴板中的,详细的介绍可以看MSDN中的相关文章

HTML在剪贴板中是以文本形式保存的,包含头信息、上下文信息、和选择的HTML块。因为HTML是一种标签式语言,需要标签嵌套和闭合,以及上面可能会有样式等信息,隐藏需要保留上下文信息。一个标准的HTML剪贴板信息应该类似于这样:

Version:1.0
StartHTML:00000097
EndHTML:00001130
StartFragment:00000228
EndFragment:00001098
<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.0 Transitional//EN">
<HTML><HEAD><TITLE>Snippet</TITLE></HEAD><BODY>
<!--StartFragment-->
<b>选择的HTML块</b>
<!--EndFragment-->
</BODY></HTML>

头信息第一行是版本号,从0.9
开始。第一行到第四行是位置标记,标记对应的字符开始位置在文档流中的索引(-1表示没有)。整个剪贴板数据看作是一个数据流的话,对应索引位置就是数据开始的位置。从
StartHTML 到 EndHTML 之间是完整的HTML全文,包含了上下文信息和选中的HTML片段,而 StartFragment 到
EndFragment 之间则是整个上下文中选中的部分(也就是粘贴所要粘贴的部分)。

值得一提的是,剪贴板中的HTML数据是以UTF-8格式进行保存的。

解释到这里,回头再看上面贴出的 GetHtmlForClipboard() 函数,相信问题的原因就很容易知道了。在
GetHtmlForClipboard() 中, length, num3, num4, x
保存的是对应上面HTML片段的四个位置,但是它们的计算都是直接计算字符串长度的。这样的算法在全英文的时候不会有任何问题,因为一个字节就是一个字符;当出现非英文字符(比如中文)时,一个字符对应了三个字节,这种算法就会出现问题,直接导致的后果是,算出来的长度不是完整的数据区长度,因此粘贴后的代码出现丢失也就不足为奇了。

修正后的函数如下:

private static string GetHtmlForClipboard(string htmlFragment)
{
    var fragmentHeader = new string[]{
        "Version:1.0rnStartHTML:",
        "EndHTML:",
        "StartFragment:",
        "EndFragment:"/*,
        "StartSelection:",
        "EndSelection:",
        "SourceUrl:file:///"*/
    };
    string documentHeader = "<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.0 Transitional//EN">rn<HTML><HEAD><TITLE>Snippet</TITLE></HEAD><BODY><!--StartFragment-->";
    string documentFoot = "<!--EndFragment--></BODY></HTML>";

    var lengthHtmlFragment = System.Text.Encoding.UTF8.GetByteCount(htmlFragment);
    //对应的位置标记
    var position = new int[fragmentHeader.Length];
    //开始HTML
    position[0] = fragmentHeader.Select(s => s.Length + 10).Sum();
    //开始片段
    position[2] = position[0] + documentHeader.Length;
    //结束片段
    position[3] = position[2] + lengthHtmlFragment;
    //结束HTML
    position[1] = position[3] + documentFoot.Length;


    StringBuilder builder = new StringBuilder(position[1]);
    //fragmetn header
    for (int i = 0; i < fragmentHeader.Length; i++)
    {
        builder.Append(fragmentHeader[i]);
        builder.Append(position[i].ToString("D8"));
        builder.Append("rn");
    }
    //document header
    builder.Append(documentHeader);
    //body
    builder.Append(htmlFragment);
    //document foot
    builder.Append(documentFoot);

    return builder.ToString();
}

这里先计算出了总长度,然后直接声明了指定容量的StringBuilder,应该可以避免反复扩容带来的性能损失。
至此,第一个问题解决。

尝试增加点功能

我打算给这个插件加个小功能,就是自动取消缩进。先解释一下为什么非要这么做。看本文开头的第一副图,截图的代码是一个函数,这个函数是一个类中的,外面套上一个命名空间,也就是说,这个函数整体是有两个Tab的缩进的。如果直接将代码插入其它地方,就会发现这个函数块不会靠左对齐,始终带着两个Tab。

之前为了避免这种情况的发生,我会在复制到剪贴板前按Shift+Tab先把缩进取消掉(直到靠左端对齐),然后复制,再撤消更改。能不能在复制的时候自动过滤呢?

CopyAsHtml的工作原理,是在复制的时候拦截命令,然后把转换后的HTML放入剪贴板中。转换为HTML的具体过程在
Microsoft.VisualStudio.Text.Formatting.Implementation.HtmlBuilderService 中,通过
GenerateHtml() 来完成转换。

public string GenerateHtml(ITextView textView)
{
    NormalizedSnapshotSpanCollection selection = this.GetSelection(textView);
    if ((selection == null) || (selection.Count == 0))
    {
        return "";
    }
    IClassificationFormatMap classificationFormatMap = this._classificationFormatMappingService.GetClassificationFormatMap(textView);
    IClassificationType classificationType = this._classificationTypeRegistry.GetClassificationType("text");
    HtmlMarkupProvider htmlMarkupProvider = new HtmlMarkupProvider(classificationFormatMap, classificationType);
    IClassifier classifier = this._classifierAggregatorService.GetClassifier(textView.TextBuffer);
    string str = new FormattedStringBuilder(htmlMarkupProvider, classifier, classificationType).AppendSnapshotSpans(selection);
    IDisposable disposable = classifier as IDisposable;
    if (disposable != null)
    {
        disposable.Dispose();
    }
    return str;
}

传入的是textView,在获得VS设置中对文本编辑器的颜色设置和对选中文本的格式化映射后,最终由 FormattedStringBuilder
来执行转换,而它核心函数如下。

public void AppendSnapshotSpan(SnapshotSpan span)
{
    IList<ClassificationSpan> classificationSpans = this.GetClassificationSpans(span);
    this.AppendBeginLine();
    foreach (ClassificationSpan span2 in classificationSpans)
    {
        this.AppendClassifiedSpan(span2);
    }
    this.AppendEndLine();
    if (!span.GetText().Contains(Environment.NewLine))
    {
        string text = ToolsOptionsPage.Instance.ReplaceLineBreaksWithBR ? "<br/>" : Environment.NewLine;
        this.Append(text);
    }
}

public string AppendSnapshotSpans(NormalizedSnapshotSpanCollection spans)
{
    this.AppendBeginCodeSnippet();
    foreach (SnapshotSpan span in spans)
    {
        this.AppendSnapshotSpan(span);
    }
    this.AppendEndCodeSnippet();
    return this._stringBuilder.ToString();
}

在VS的文本编辑器中,SnapshotSpan
是最小的单位,可以理解为加色的最小单位(研究后的结果,不是太确定,如有错误请指出)。在一个源代码文档中,将会以关键字、变量、函数名、各种运算符号、分界符(如分号换行空格等)作为划分,形成最小的着色块(仅就显示而言,当然这并不是单单为了加色而进行的处理)。我们要单独处理的块缩进,就藏在其中。

这里需要考虑的是,并不是要取消所有的块缩进,而是取消公共的块缩进。比如整个代码块贴出来的结果是所有的行都至少有2个Tab的缩进,那么这两个Tab的缩进其实是可以不要的,为了尽可能容纳更多的代码内容,需要删除它。

而在 SnapshotSpan 中,这样的块缩进往往是和其它分界符一起出现的,往往交错其中。比如下面的代码:

for (var i = 0; i < 10; i++)
    Console.Write(i.ToString());

第二行之前的Tab,就是和第一行最后的回车一起出现的。在C#语言中,分号 ; 和花括号 { } 也是分界符,当它们之前或之后遇到缩进时,是作为一个
SnapshotSpan 出现的。好在我们可以对每个 SnapshotSpan 取得所有的文本内容,那么在 AppendSnapshotSpans
中其实我们就能检测到所有的块缩进。

添加一个 FetchBlankText() 函数来检测共同的块缩进:

static readonly System.Text.RegularExpressions.Regex BlankReg = new System.Text.RegularExpressions.Regex(@"[rn]( +)");

///<summary>
/// 获得空白字符串
///</summary>
///<param name="code"></param>
///<returns></returns>
static int FetchBlankText(string code)
{
    //replace tabs with space
    code = code.Replace("t", "    ");

    //不参考第一行分析各行
    var mc = BlankReg.Matches(code);

    if (mc.Count == 0) return 0;
    else return mc.Cast<System.Text.RegularExpressions.Match>().Select(s => s.Length).Min() - 1;
}

直接使用正则表达式检查每个换行符后面共同具有的最小长度的空白字符串。为了方便,先把Tab替换为4个空格,保持样式不变简化操作。

而为了偷懒,这里省去了对第一行的缩进检查(除非第一行之前有空行,否则前面不可能有换行符),因为懒,还要考虑到空行等缘故。而我们复制的时候通常不会复制一两行代码,往往是以块为单位的,所以这个问题不是很严重——个别场合,比如上面的
for
循环并且后面没有花括号,就会出问题,第一行缩进没去掉而第二行的却被去掉了,就是因为第一行缩进没有考察的原因——这个问题应该不严重,待考察,如果发现问题有点明显的话还是要处理的——如果确实需要这样的复制代码,可以在之前多选择一行代码,比如选择一个空行什么的。

为了减少长度,之间无关的代码不贴,有兴趣的可以看项目源码。SnapshotSpan 最终是进入到 HtmlMarkupProvider
类中进行处理的,修改的处理函数如下:

///<summary>
/// 转换每个块
///</summary>
///<param name="classifiedSpan"></param>
///<returns></returns>
public string GetMarkupForSpan(ClassificationSpan classifiedSpan, string removeWords)
{   var str = classifiedSpan.Span.GetText();

    if (!string.IsNullOrEmpty(removeWords) && str[0] != ''' && str[0] != '"' && (str.Length > 1 && str[1] != ''' && str[1] != '"'))    //ignore for string '',"",@"",@''
    {
        str = str.Replace("t", "    ");
        var index = str.IndexOf(removeWords);
        while (index != -1 && index < str.Length)
        {
            str = str.Remove(index, removeWords.Length);
            //查找下一个非空格字符,并继续匹配空格
            while (index < str.Length && str[index] == ' ')
            {
                index++;
            }
            if (index < str.Length) index = str.IndexOf(removeWords, index++);
        }
    }

    str = HtmlEscape(str);
    if (classifiedSpan.ClassificationType.Classification == this._defaultClassificationType.Classification)
    {
        return str;
    }
    StringBuilder builder = new StringBuilder(Math.Max(str.Length + 40, 40));
    builder.Append("<span");
    if (ToolsOptionsPage.Instance.EmitSpanClass)
    {
        builder.Append(" class="");
        builder.Append(classifiedSpan.ClassificationType.Classification);
        builder.Append(""");
    }
    string cSS = this.GetCSS(classifiedSpan);
    if (!ToolsOptionsPage.Instance.EmitSpanClass && (!ToolsOptionsPage.Instance.EmitSpanStyle || string.IsNullOrEmpty(cSS)))
    {
        return str;
    }
    if (ToolsOptionsPage.Instance.EmitSpanStyle && !string.IsNullOrEmpty(cSS))
    {
        builder.Append(" style="");
        builder.Append(cSS);
        builder.Append(""");
    }
    builder.Append(">");
    builder.Append(str);
    builder.Append("</span>");
    return builder.ToString();
}

在 str = HtmlEscape(str); 之前的代码,就是添加的去掉块缩进的代码。第一个 if
是为了防止源码中带有字符串的时候,字符串中的tab和空格被误删(字符串是带上定界符作为一个单独的 SnapshotSpan
出现的)。之后依旧替换Tab为空格,然后检索空白并删除。这里的算法试验了很多种,最终确定为这个方式,因为一个 SnapshotSpan
中的空白可能有很多,而当缩进很多的时候往往空白又连续很长,所以不能一次性的Replace,而要找到第一个,删除,接着继续索引,直到找到下一个非空字符(防止很长的缩进被误删),然后继续检索是否带有不需要的缩进。

当把缩进都删除了,再把处理后的文本加样式返回,就可以了。

在更改的过程中,修改了几处初始化 StringBuilder 的代码,指定了一个估计的可能长度,防止它需要反复扩容对性能产生负面影响。

同时,修改默认值给生成的最终代码块加上了 class=”codeBlock” 的样式,也就是说可以通过定义样式表来修改代码块的样式。

 

有点小插曲

修改的过程有点小插曲,在Windows Live Writer
中,无论怎么粘贴都粘贴不出需要的效果,急死个人。后来才发现WLW在粘贴的过程中默认删除了所有的效果,晕倒。要粘贴这样的代码,可以按 Ctrl+Shift+V
,出现的对话框中选择最下面的“保持格式”的粘贴,或点击右键选择“选择性粘贴..”才能保留所有的效果。

修改后的DLL和源代码

DLL下载:http://files.cnblogs.com/nicch/CopyAsHtml.zip

源代码下载:http://files.cnblogs.com/nicch/CopyAsHtml_src.zip

源代码是VS2010工程,直接编译即可。DLL请在关闭VS后复制到对应位置(%localappdata%MicrosoftVisualStudio10.0ExtensionsMicrosoftVisual
Studio 2010 Pro Power Tools),覆盖即可。

欢迎反馈。

本日志备份自 QQ 空间,原文地址:http://user.qzone.qq.com/286495995/blog/1276414979

喜欢 (0)
发表我的评论
取消评论
表情

Hi,您需要填写昵称和邮箱!

  • 昵称 (必填)
  • 邮箱 (必填)
  • 网址
(4)个小伙伴在吐槽
  1. 轻点儿宅

    王君一2010-06-13 03:50 回复