使用C#内联CSS

29
我需要在C#中从样式表中内联CSS。
就像这个例子一样。

http://www.mailchimp.com/labs/inlinecss.php

这个CSS很简单,只有类,没有花哨的选择器。

我正在考虑使用正则表达式(?<rule>(?<selector>[^{}]+){(?<style>[^{}]+)})+从CSS中剥离规则,然后尝试进行简单的字符串替换,其中调用类,但是一些HTML元素已经有样式标签,所以我也必须考虑到这一点。

有更简单的方法吗?或者C#中已经有写好的东西了吗?

更新-2010年9月16日

如果您的HTML也是有效的XML,我已经能够提供一个简单的CSS内联程序。它使用正则表达式获取<style />元素中的所有样式。然后将CSS选择器转换为XPath表达式,并在匹配元素之前添加样式内联,而不是任何现有的内联样式。

请注意,CssToXpath并未完全实现,有些事情它还无法做到...但是。

CssInliner.cs

using System.Collections.Generic;
using System.Text.RegularExpressions;
using System.Xml.Linq;
using System.Xml.XPath;

namespace CssInliner
{
    public class CssInliner
    {
        private static Regex _matchStyles = new Regex("\\s*(?<rule>(?<selector>[^{}]+){(?<style>[^{}]+)})",
                                                RegexOptions.IgnoreCase
                                                | RegexOptions.CultureInvariant
                                                | RegexOptions.IgnorePatternWhitespace
                                                | RegexOptions.Compiled
                                            );

        public List<Match> Styles { get; private set; }
        public string InlinedXhtml { get; private set; }

        private XElement XhtmlDocument { get; set; }

        public CssInliner(string xhtml)
        {
            XhtmlDocument = ParseXhtml(xhtml);
            Styles = GetStyleMatches();

            foreach (var style in Styles)
            {
                if (!style.Success)
                    return;

                var cssSelector = style.Groups["selector"].Value.Trim();
                var xpathSelector = CssToXpath.Transform(cssSelector);
                var cssStyle = style.Groups["style"].Value.Trim();

                foreach (var element in XhtmlDocument.XPathSelectElements(xpathSelector))
                {
                    var inlineStyle = element.Attribute("style");

                    var newInlineStyle = cssStyle + ";";
                    if (inlineStyle != null && !string.IsNullOrEmpty(inlineStyle.Value))
                    {
                        newInlineStyle += inlineStyle.Value;
                    }

                    element.SetAttributeValue("style", newInlineStyle.Trim().NormalizeCharacter(';').NormalizeSpace());
                }
            }

            XhtmlDocument.Descendants("style").Remove();
            InlinedXhtml = XhtmlDocument.ToString();
        }

        private List<Match> GetStyleMatches()
        {
            var styles = new List<Match>();

            var styleElements = XhtmlDocument.Descendants("style");
            foreach (var styleElement in styleElements)
            {
                var matches = _matchStyles.Matches(styleElement.Value);

                foreach (Match match in matches)
                {
                    styles.Add(match);
                }
            }

            return styles;
        }

        private static XElement ParseXhtml(string xhtml)
        {
            return XElement.Parse(xhtml);
        }
    }
}

CssToXpath.cs

using System.Text.RegularExpressions;

namespace CssInliner
{
    public static class CssToXpath
    {
        public static string Transform(string css)
        {
            #region Translation Rules
            // References:  http://ejohn.org/blog/xpath-css-selectors/
            //              http://code.google.com/p/css2xpath/source/browse/trunk/src/css2xpath.js
            var regexReplaces = new[] {
                                          // add @ for attribs
                                          new RegexReplace {
                                              Regex = new Regex(@"\[([^\]~\$\*\^\|\!]+)(=[^\]]+)?\]", RegexOptions.Multiline),
                                              Replace = @"[@$1$2]"
                                          },
                                          //  multiple queries
                                          new RegexReplace {
                                              Regex = new Regex(@"\s*,\s*", RegexOptions.Multiline),
                                              Replace = @"|"
                                          },
                                          // , + ~ >
                                          new RegexReplace {
                                              Regex = new Regex(@"\s*(\+|~|>)\s*", RegexOptions.Multiline),
                                              Replace = @"$1"
                                          },
                                          //* ~ + >
                                          new RegexReplace {
                                              Regex = new Regex(@"([a-zA-Z0-9_\-\*])~([a-zA-Z0-9_\-\*])", RegexOptions.Multiline),
                                              Replace = @"$1/following-sibling::$2"
                                          },
                                          new RegexReplace {
                                              Regex = new Regex(@"([a-zA-Z0-9_\-\*])\+([a-zA-Z0-9_\-\*])", RegexOptions.Multiline),
                                              Replace = @"$1/following-sibling::*[1]/self::$2"
                                          },
                                          new RegexReplace {
                                              Regex = new Regex(@"([a-zA-Z0-9_\-\*])>([a-zA-Z0-9_\-\*])", RegexOptions.Multiline),
                                              Replace = @"$1/$2"
                                          },
                                          // all unescaped stuff escaped
                                          new RegexReplace {
                                              Regex = new Regex(@"\[([^=]+)=([^'|""][^\]]*)\]", RegexOptions.Multiline),
                                              Replace = @"[$1='$2']"
                                          },
                                          // all descendant or self to //
                                          new RegexReplace {
                                              Regex = new Regex(@"(^|[^a-zA-Z0-9_\-\*])(#|\.)([a-zA-Z0-9_\-]+)", RegexOptions.Multiline),
                                              Replace = @"$1*$2$3"
                                          },
                                          new RegexReplace {
                                              Regex = new Regex(@"([\>\+\|\~\,\s])([a-zA-Z\*]+)", RegexOptions.Multiline),
                                              Replace = @"$1//$2"
                                          },
                                          new RegexReplace {
                                              Regex = new Regex(@"\s+\/\/", RegexOptions.Multiline),
                                              Replace = @"//"
                                          },
                                          // :first-child
                                          new RegexReplace {
                                              Regex = new Regex(@"([a-zA-Z0-9_\-\*]+):first-child", RegexOptions.Multiline),
                                              Replace = @"*[1]/self::$1"
                                          },
                                          // :last-child
                                          new RegexReplace {
                                              Regex = new Regex(@"([a-zA-Z0-9_\-\*]+):last-child", RegexOptions.Multiline),
                                              Replace = @"$1[not(following-sibling::*)]"
                                          },
                                          // :only-child
                                          new RegexReplace {
                                              Regex = new Regex(@"([a-zA-Z0-9_\-\*]+):only-child", RegexOptions.Multiline),
                                              Replace = @"*[last()=1]/self::$1"
                                          },
                                          // :empty
                                          new RegexReplace {
                                              Regex = new Regex(@"([a-zA-Z0-9_\-\*]+):empty", RegexOptions.Multiline),
                                              Replace = @"$1[not(*) and not(normalize-space())]"
                                          },
                                          // |= attrib
                                          new RegexReplace {
                                              Regex = new Regex(@"\[([a-zA-Z0-9_\-]+)\|=([^\]]+)\]", RegexOptions.Multiline),
                                              Replace = @"[@$1=$2 or starts-with(@$1,concat($2,'-'))]"
                                          },
                                          // *= attrib
                                          new RegexReplace {
                                              Regex = new Regex(@"\[([a-zA-Z0-9_\-]+)\*=([^\]]+)\]", RegexOptions.Multiline),
                                              Replace = @"[contains(@$1,$2)]"
                                          },
                                          // ~= attrib
                                          new RegexReplace {
                                              Regex = new Regex(@"\[([a-zA-Z0-9_\-]+)~=([^\]]+)\]", RegexOptions.Multiline),
                                              Replace = @"[contains(concat(' ',normalize-space(@$1),' '),concat(' ',$2,' '))]"
                                          },
                                          // ^= attrib
                                          new RegexReplace {
                                              Regex = new Regex(@"\[([a-zA-Z0-9_\-]+)\^=([^\]]+)\]", RegexOptions.Multiline),
                                              Replace = @"[starts-with(@$1,$2)]"
                                          },
                                          // != attrib
                                          new RegexReplace {
                                              Regex = new Regex(@"\[([a-zA-Z0-9_\-]+)\!=([^\]]+)\]", RegexOptions.Multiline),
                                              Replace = @"[not(@$1) or @$1!=$2]"
                                          },
                                          // ids
                                          new RegexReplace {
                                              Regex = new Regex(@"#([a-zA-Z0-9_\-]+)", RegexOptions.Multiline),
                                              Replace = @"[@id='$1']"
                                          },
                                          // classes
                                          new RegexReplace {
                                              Regex = new Regex(@"\.([a-zA-Z0-9_\-]+)", RegexOptions.Multiline),
                                              Replace = @"[contains(concat(' ',normalize-space(@class),' '),' $1 ')]"
                                          },
                                          // normalize multiple filters
                                          new RegexReplace {
                                              Regex = new Regex(@"\]\[([^\]]+)", RegexOptions.Multiline),
                                              Replace = @" and ($1)"
                                          },

                                      };
            #endregion

            foreach (var regexReplace in regexReplaces)
            {
                css = regexReplace.Regex.Replace(css, regexReplace.Replace);
            }

            return "//" + css;
        }
    }

    struct RegexReplace
    {
        public Regex Regex;
        public string Replace;
    }
}

还有一些测试

    [TestMethod]
    public void TestCssToXpathRules()
    {
        var translations = new Dictionary<string, string>
                               {
                                   { "*", "//*" }, 
                                   { "p", "//p" }, 
                                   { "p > *", "//p/*" }, 
                                   { "#foo", "//*[@id='foo']" }, 
                                   { "*[title]", "//*[@title]" }, 
                                   { ".bar", "//*[contains(concat(' ',normalize-space(@class),' '),' bar ')]" }, 
                                   { "div#test .note span:first-child", "//div[@id='test']//*[contains(concat(' ',normalize-space(@class),' '),' note ')]//*[1]/self::span" }
                               };

        foreach (var translation in translations)
        {
            var expected = translation.Value;
            var result = CssInliner.CssToXpath.Transform(translation.Key);

            Assert.AreEqual(expected, result);
        }
    }

    [TestMethod]
    public void HtmlWithMultiLineClassStyleReturnsInline()
    {
        #region var html = ...
        var html = XElement.Parse(@"<html>
                                        <head>
                                            <title>Hello, World Page!</title>
                                            <style>
                                                .redClass { 
                                                    background: red; 
                                                    color: purple; 
                                                }
                                            </style>
                                        </head>
                                        <body>
                                            <div class=""redClass"">Hello, World!</div>
                                        </body>
                                    </html>").ToString();
        #endregion

        #region const string expected ...
        var expected = XElement.Parse(@"<html>
                                            <head>
                                                <title>Hello, World Page!</title>
                                            </head>
                                            <body>
                                                <div class=""redClass"" style=""background: red; color: purple;"">Hello, World!</div>
                                            </body>
                                        </html>").ToString();
        #endregion

        var result = new CssInliner.CssInliner(html);

        Assert.AreEqual(expected, result.InlinedXhtml);
    }

还有更多的测试,但是它们导入HTML文件作为输入和期望输出,我不会全部发布!

但我应该发布规范化扩展方法!

private static readonly Regex NormalizeSpaceRegex = new Regex(@"\s{2,}", RegexOptions.None);
public static string NormalizeSpace(this string data)
{
    return NormalizeSpaceRegex.Replace(data, @" ");
}

public static string NormalizeCharacter(this string data, char character)
{
    var normalizeCharacterRegex = new Regex(character + "{2,}", RegexOptions.None);
    return normalizeCharacterRegex.Replace(data, character.ToString());
}

已添加悬赏,希望有人已经在.NET中有相关内容。 - CaffGeek
希望你能得到一些回应,我不喜欢我的答案。 - Greg
@Greg,我也是!我试图写一些简单的东西...但它并不简单... - CaffGeek
我添加了我解决问题所使用的代码。请随意改进它。CssToXpath类肯定可以进行更多的增强,但目前它已经满足我的需求。 - CaffGeek
2
嗨,我刚刚在我的博客上发布了关于这个问题的解决方案,使用 PreMailer.Net:http://martinnormark.com/move-css-inline-premailer-net - MartinHN
8个回答

17

不错。看起来大部分都能正常工作,但是当我尝试在Zurb“Ink基本模板”中使用它时,会出现以下警告:“由于CsQuery的限制,PreMailer.Net无法处理伪类/元素'a:active'。” - 我想这取决于CsQuery来解决。顺便说一句,这篇文章说CsQuery现在支持它们了https://github.com/milkshakesoftware/PreMailer.Net/issues/34 - Matthew Lock
1
@MatthewLock 是的,目前存在一个问题,就像你链接到的GitHub问题中描述的那样。 - MartinHN
亲爱的@MartinHN,感谢您提供这个友善的项目。当我使用MoveCssInline时,我遇到了一个问题,出现了这个异常(在第34个字符位置找到意外字符:“.. rol-solid::>>-<<moz-placeholder)。请帮忙解决一下吗? - Younis Qadir
@Younisbarznji,您能否将您的源代码粘贴到这里的新问题中吗? - MartinHN

9

既然你已经完成了现有实现的90%,为什么不继续使用现有框架,但将XML解析替换为HTML解析器呢?其中一个更受欢迎的解析器是HTML Agility Pack。它支持XPath查询,甚至具有类似于标准.NET接口提供的XML的LINQ接口,因此应该是一个相对简单的替换。


如果我在当前的实现中遇到任何问题,我会研究一下。现在,我知道我的“html”是有效的xml,所以xml解析没问题,并且它在生产环境中运行良好。 - CaffGeek
另外,HTML Agility Pack 的解析器甚至不能很好地处理有效的 HTML,例如 <ul><li>x<li>y<li>z</ul> - 它无法足够强大地处理现实世界中的 HTML。 - Eamon Nerbonne
请查看此线程(http://stackoverflow.com/questions/100358/looking-for-c-html-parser),了解其他HTML解析器以及HTML Agility Pack的讨论。 - Richard Cook

9

由于其他回答中这个选项并不是很清晰,我认为它值得一个直接的回答。

使用PreMailer.Net

你所需要做的就是:

  1. Install PreMailer.NET via nuget.
  2. Type this:

    var inlineStyles = PreMailer.Net.PreMailer.MoveCssInline(htmlSource, false);
    destination = inlineStyles.Html;
    

你已经完成了!

顺便说一下,你可能想要添加一个 using 指令来缩短那行代码。

当然,在上面的链接中可以找到更多的使用信息。


4

非常好的问题。

我不知道是否有.NET解决方案,但我找到了一个名为Premailer的Ruby程序,声称可以内联CSS。如果您想使用它,您有几个选择:

  1. 用C#(或任何您熟悉的.NET语言)重写Premailer
  2. 使用IronRuby在.NET中运行Ruby

4
PreMailer有一个.NET版本,名为PreMailer.Net。您可以在https://github.com/milkshakesoftware/PreMailer.Net找到它。 - Matthew Lock

3
我建议使用实际的CSS解析器而不是正则表达式。您不需要解析整个语言,因为您主要感兴趣的是复制,但在任何情况下,这样的解析器都是可用的(也适用于.NET)。例如,请查看antlr的语法列表,特别是CSS 2.1语法CSS3语法。如果您不介意行内样式可能包含重复定义,则可以可能剥离两个语法的大部分内容,但为了做到这一点,您需要一些内部CSS逻辑的想法,以便能够解决简写属性。
然而,从长远来看,这肯定比永无止境的临时正则表达式修复要少得多。

如果这成为一个问题,我可能会这样做。 - CaffGeek

1

这里有一个想法,为什么不使用C#向http://www.mailchimp.com/labs/inlinecss.php发起POST调用呢?通过使用Firebug进行分析,看起来POST调用需要2个参数htmlstrip,它们取值为(on/off),结果在一个名为text的参数中。

这里是一个使用C#进行POST调用的示例


2
这个应用程序非常重要,不能依赖于其他网站的在线状态、速度和不变性。 - CaffGeek

1
Chad,你一定要添加CSS内联吗?或者你可以通过将<style>块添加到<head>中来更好地完成工作吗?这实质上将替换对CSS文件的引用,并保持实际内联规则覆盖头部/引用CSS文件中设置的规则的规则。
(抱歉,忘记为代码添加引号)

是的,CSS已经在<style/>元素中了,但BB电子邮件客户端似乎不支持它。但它确实支持内联样式。 - CaffGeek
有多个原因使CSS内联在HTML邮件中得到使用。主要原因是,许多网络电邮客户端会从HTML电子邮件源中剥离现有的样式块,然后将其包含到自己的HTML输出中。此外还有Outlook,即使在2019年,它仍然是一个可怕的HTML电子邮件客户端,如果不对CSS样式进行内联,你永远无法控制它。 - Jpsy

1
我建议使用这样的字典:
private Dictionary<string, Dictionary<string, string>> cssDictionary = new Dictionary<string, Dictionary<string, string>();

我会解析CSS并填充cssDictionary。
(添加“style-type”,“style-property”,“value”。例如:)
Dictionary<string,string> bodyStyleDictionary = new Dictionary<string, string();
    bodyStyleDictionary.Add("background", "#000000");
    cssDictionary.Add("body", bodyStyleDictionary);

接下来,我更倾向于将HTML转换为XmlDocument。

您可以通过其子节点递归运行文档节点,并查找其父节点(这甚至使您能够使用选择器)。

对于每个元素,您都要检查元素类型、ID和类。然后,您浏览cssDictionary以将此元素的任何样式添加到style属性中(如果它们具有重叠属性,则可能要按出现顺序放置它们(并将现有的内联样式添加到最后)。

完成后,您将xmlDocument作为字符串发出,并删除第一行(<?xml version="1.0"?>)。这应该让您拥有一个带有内联CSS的有效HTML文档。

当然,它可能看起来像是一个黑客,但最终我认为这是一个相当可靠的解决方案,确保稳定性并且基本上做到了您似乎正在寻找的东西。


我其实已经写了类似的东西,而且效果还不错。虽然它远非完美,但是它运作地相当好。我会试着打包它并在这里发布。 - CaffGeek

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接