diff --git a/src/AngleSharp.Css.Tests/Styling/CssSheet.cs b/src/AngleSharp.Css.Tests/Styling/CssSheet.cs index cf2d448..eb3a6f6 100644 --- a/src/AngleSharp.Css.Tests/Styling/CssSheet.cs +++ b/src/AngleSharp.Css.Tests/Styling/CssSheet.cs @@ -767,6 +767,225 @@ public void CssSheetWithDataUrlAsBackgroundImage() Assert.AreEqual("71px", decl.GetWidth()); } + [Test] + public void CssSheetWithUnquotedDataUrlAsBackgroundImage() + { + var sheet = ParseStyleSheet("a { background-image: url(data:image/svg+xml;base64,PHN2Zz48L3N2Zz4=); color: red }"); + var rule = sheet.Rules[0] as CssStyleRule; + Assert.IsNotNull(rule); + Assert.AreEqual(2, rule.Style.Length); + var decl = rule.Style as ICssStyleDeclaration; + Assert.AreEqual("url(\"data:image/svg+xml;base64,PHN2Zz48L3N2Zz4=\")", decl.GetBackgroundImage()); + } + + [Test] + public void CssSheetWithUnquotedUrlKeepsSemicolonAsLastDeclaration() + { + var sheet = ParseStyleSheet("a { background-image: url(data:image/svg+xml;base64,PHN2Zz48L3N2Zz4=) }"); + var rule = sheet.Rules[0] as CssStyleRule; + Assert.IsNotNull(rule); + Assert.AreEqual(1, rule.Style.Length); + var decl = rule.Style as ICssStyleDeclaration; + Assert.AreEqual("url(\"data:image/svg+xml;base64,PHN2Zz48L3N2Zz4=\")", decl.GetBackgroundImage()); + } + + [Test] + public void CssSheetWithUnquotedUrlIsCaseInsensitive() + { + var sheet = ParseStyleSheet("a { background-image: URL(data:x;y); color: red }"); + var decl = (sheet.Rules[0] as CssStyleRule).Style as ICssStyleDeclaration; + Assert.AreEqual("url(\"data:x;y\")", decl.GetBackgroundImage()); + Assert.AreEqual("rgba(255, 0, 0, 1)", decl.GetColor()); + } + + [Test] + public void CssSheetWithUnquotedUrlContainingCurlyBrace() + { + var sheet = ParseStyleSheet("a { background-image: url(a}b); color: red }"); + var decl = (sheet.Rules[0] as CssStyleRule).Style as ICssStyleDeclaration; + Assert.AreEqual("url(\"a}b\")", decl.GetBackgroundImage()); + Assert.AreEqual("rgba(255, 0, 0, 1)", decl.GetColor()); + } + + [Test] + public void CssSheetWithUnquotedUrlContainingEscapedSemicolon() + { + var sheet = ParseStyleSheet("a { background-image: url(a\\;b); color: red }"); + var decl = (sheet.Rules[0] as CssStyleRule).Style as ICssStyleDeclaration; + Assert.AreEqual("url(\"a;b\")", decl.GetBackgroundImage()); + Assert.AreEqual("rgba(255, 0, 0, 1)", decl.GetColor()); + } + + [Test] + public void CssSheetWithUnquotedUrlSurroundedByWhitespace() + { + var sheet = ParseStyleSheet("a { background-image: url( data:image/svg+xml;base64,AAA= ); color: red }"); + var decl = (sheet.Rules[0] as CssStyleRule).Style as ICssStyleDeclaration; + Assert.AreEqual("url(\"data:image/svg+xml;base64,AAA=\")", decl.GetBackgroundImage()); + } + + [Test] + public void CssSheetWithQuotedUrlContainingClosingParenthesis() + { + var sheet = ParseStyleSheet("a { background-image: url( \"a)b\" ); color: red }"); + var decl = (sheet.Rules[0] as CssStyleRule).Style as ICssStyleDeclaration; + Assert.AreEqual("url(\"a)b\")", decl.GetBackgroundImage()); + Assert.AreEqual("rgba(255, 0, 0, 1)", decl.GetColor()); + } + + [Test] + public void CssSheetWithUnquotedUrlInShorthandAndImportant() + { + var sheet = ParseStyleSheet("a { background: url(x;y) no-repeat !important; color: red }"); + var rule = sheet.Rules[0] as CssStyleRule; + var decl = rule.Style as ICssStyleDeclaration; + Assert.AreEqual("url(\"x;y\")", decl.GetBackgroundImage()); + Assert.AreEqual("important", decl.GetPropertyPriority("background")); + Assert.AreEqual("rgba(255, 0, 0, 1)", decl.GetColor()); + } + + [Test] + public void CssSheetWithUnquotedUrlInsideMediaRule() + { + var sheet = ParseStyleSheet("@media (min-width:1px) { a { background-image: url(data:image/svg+xml;base64,QQ==); color: red } }"); + var media = sheet.Rules[0] as CssMediaRule; + Assert.IsNotNull(media); + var decl = (media.Rules[0] as CssStyleRule).Style as ICssStyleDeclaration; + Assert.AreEqual("url(\"data:image/svg+xml;base64,QQ==\")", decl.GetBackgroundImage()); + } + + [Test] + public void CssSheetWithUnquotedUrlInSupportsCondition() + { + var sheet = ParseStyleSheet("@supports (background-image: url(a;b)) { a { color: red } }"); + Assert.AreEqual(1, sheet.Rules.Length); + var supports = sheet.Rules[0] as CssSupportsRule; + Assert.IsNotNull(supports); + Assert.AreEqual("(background-image: url(a;b))", supports.ConditionText); + } + + [Test] + public void CssSheetWithFunctionEndingInUrlIsNotAUrlToken() + { + var sheet = ParseStyleSheet("a { background-image: myurl(a;b); color: red }"); + var rule = sheet.Rules[0] as CssStyleRule; + Assert.AreEqual(1, rule.Style.Length); + var decl = rule.Style as ICssStyleDeclaration; + Assert.AreEqual("rgba(255, 0, 0, 1)", decl.GetColor()); + } + + [Test] + public void CssSheetWithEmptyUrlKeepsTheDeclaration() + { + var sheet = ParseStyleSheet("a { background-image: url(); color: red }"); + var rule = sheet.Rules[0] as CssStyleRule; + Assert.AreEqual(2, rule.Style.Length); + var decl = rule.Style as ICssStyleDeclaration; + Assert.AreEqual("url(\"\")", decl.GetBackgroundImage()); + Assert.AreEqual("rgba(255, 0, 0, 1)", decl.GetColor()); + } + + [Test] + public void CssSheetWithEmptyUrlContainingSpacesKeepsTheDeclaration() + { + var sheet = ParseStyleSheet("a { background-image: url( ); color: red }"); + var decl = (sheet.Rules[0] as CssStyleRule).Style as ICssStyleDeclaration; + Assert.AreEqual("url(\"\")", decl.GetBackgroundImage()); + } + + [Test] + public void CssSheetWithWhitespaceInsideUnquotedUrlDropsTheDeclaration() + { + var sheet = ParseStyleSheet("a { background-image: url(a b); color: red }"); + var rule = sheet.Rules[0] as CssStyleRule; + Assert.AreEqual(1, rule.Style.Length); + var decl = rule.Style as ICssStyleDeclaration; + Assert.AreEqual("rgba(255, 0, 0, 1)", decl.GetColor()); + } + + [Test] + public void CssSheetWithParenthesisInsideUnquotedUrlDropsTheDeclaration() + { + var sheet = ParseStyleSheet("a { background-image: url(a(b); color: red }"); + var rule = sheet.Rules[0] as CssStyleRule; + Assert.AreEqual(1, rule.Style.Length); + var decl = rule.Style as ICssStyleDeclaration; + Assert.AreEqual("rgba(255, 0, 0, 1)", decl.GetColor()); + } + + [Test] + public void CssSheetWithQuoteInsideUnquotedUrlDropsTheDeclaration() + { + var sheet = ParseStyleSheet("a { background-image: url(a\"b); color: red }"); + var rule = sheet.Rules[0] as CssStyleRule; + Assert.AreEqual(1, rule.Style.Length); + var decl = rule.Style as ICssStyleDeclaration; + Assert.AreEqual("rgba(255, 0, 0, 1)", decl.GetColor()); + } + + [Test] + public void CssSheetWithBadUrlInShorthandDropsTheWholeDeclaration() + { + var sheet = ParseStyleSheet("a { background: url(a b) red }"); + var rule = sheet.Rules[0] as CssStyleRule; + Assert.AreEqual(0, rule.Style.Length); + } + + [Test] + public void CssSheetWithBadUrlDoesNotAffectFollowingRules() + { + var sheet = ParseStyleSheet("a { background-image: url(a b) } b { color: red }"); + Assert.AreEqual(2, sheet.Rules.Length); + Assert.AreEqual(0, (sheet.Rules[0] as CssStyleRule).Style.Length); + var decl = (sheet.Rules[1] as CssStyleRule).Style as ICssStyleDeclaration; + Assert.AreEqual("rgba(255, 0, 0, 1)", decl.GetColor()); + } + + [Test] + public void CssSheetWithUnterminatedUnquotedUrlKeepsTheDeclaration() + { + var sheet = ParseStyleSheet("a { background-image: url(abc"); + var decl = (sheet.Rules[0] as CssStyleRule).Style as ICssStyleDeclaration; + Assert.AreEqual("url(\"abc\")", decl.GetBackgroundImage()); + } + + [Test] + public void CssSheetImportWithBadUrlIsDropped() + { + var sheet = ParseStyleSheet("@import url(a b); a { color: red }"); + Assert.AreEqual(1, sheet.Rules.Length); + Assert.IsInstanceOf(sheet.Rules[0]); + } + + [Test] + public void CssSheetImportWithUnterminatedUrlIsKept() + { + var sheet = ParseStyleSheet("@import url(abc"); + Assert.AreEqual(1, sheet.Rules.Length); + var import = sheet.Rules[0] as CssImportRule; + Assert.IsNotNull(import); + Assert.AreEqual("abc", import.Href); + } + + [Test] + public void CssSheetNamespaceWithBadUrlIsDropped() + { + var sheet = ParseStyleSheet("@namespace x url(a b); a { color: red }"); + Assert.AreEqual(1, sheet.Rules.Length); + Assert.IsInstanceOf(sheet.Rules[0]); + } + + [Test] + public void CssSheetNamespaceAcceptsAStringUri() + { + var sheet = ParseStyleSheet("@namespace x \"http://foo\"; a { color: red }"); + Assert.AreEqual(2, sheet.Rules.Length); + var ns = sheet.Rules[0] as CssNamespaceRule; + Assert.IsNotNull(ns); + Assert.AreEqual("x", ns.Prefix); + Assert.AreEqual("http://foo", ns.NamespaceUri); + } + [Test] public void CssSheetFromStreamWeirdBytesLeadingToInfiniteLoop() { diff --git a/src/AngleSharp.Css.Tests/Values/ErrorHandling.cs b/src/AngleSharp.Css.Tests/Values/ErrorHandling.cs index 4cfcbd2..7fff393 100644 --- a/src/AngleSharp.Css.Tests/Values/ErrorHandling.cs +++ b/src/AngleSharp.Css.Tests/Values/ErrorHandling.cs @@ -11,8 +11,10 @@ namespace AngleSharp.Css.Tests.Values public class ErrorHandlingTests { [Test] - public void ParseInlineStyleWithToleratedInvalidValueShouldReturnThatValue() + public void ParseInlineStyleWithBadUnquotedUrlShouldDropThatDeclaration() { + // An unquoted url() may not contain '(' - that makes it a bad url, and + // the whole declaration is dropped rather than guessing at its value. var source = "
"; var document = ParseDocument(source, new CssParserOptions { @@ -20,6 +22,19 @@ public void ParseInlineStyleWithToleratedInvalidValueShouldReturnThatValue() IsIncludingUnknownRules = true }); var div = document.QuerySelector("div"); + Assert.AreEqual(0, div.GetStyle().Length); + } + + [Test] + public void ParseInlineStyleWithQuotedUrlShouldReturnThatValue() + { + var source = "
"; + var document = ParseDocument(source, new CssParserOptions + { + IsIncludingUnknownDeclarations = true, + IsIncludingUnknownRules = true + }); + var div = document.QuerySelector("div"); Assert.AreEqual(1, div.GetStyle().Length); Assert.AreEqual("background-image", div.GetStyle()[0]); Assert.AreEqual("url(\"javascript:alert(1)\")", div.GetStyle().GetBackgroundImage()); diff --git a/src/AngleSharp.Css/Parser/CssBuilder.cs b/src/AngleSharp.Css/Parser/CssBuilder.cs index 344369c..52ad785 100644 --- a/src/AngleSharp.Css/Parser/CssBuilder.cs +++ b/src/AngleSharp.Css/Parser/CssBuilder.cs @@ -73,6 +73,7 @@ public ICssRule CreateRule(ICssStyleSheet sheet, CssToken token) case CssTokenType.String: case CssTokenType.Url: + case CssTokenType.BadUrl: case CssTokenType.CurlyBracketClose: case CssTokenType.RoundBracketClose: case CssTokenType.SquareBracketClose: @@ -267,11 +268,14 @@ private CssNamespaceRule CreateNamespace(CssNamespaceRule rule, CssToken current rule.Prefix = GetRuleName(ref token); CollectTrivia(rule.Owner, ref token); - if (token.Type == CssTokenType.Url) + if (!token.Is(CssTokenType.String, CssTokenType.Url)) { - rule.NamespaceUri = token.Data; + RaiseErrorOccurred(CssParseError.InvalidToken, token.Position); + JumpToEnd(ref token); + return null; } + rule.NamespaceUri = token.Data; JumpToEnd(ref token); return rule; } diff --git a/src/AngleSharp.Css/Parser/CssTokenType.cs b/src/AngleSharp.Css/Parser/CssTokenType.cs index e3d2822..82d85e2 100644 --- a/src/AngleSharp.Css/Parser/CssTokenType.cs +++ b/src/AngleSharp.Css/Parser/CssTokenType.cs @@ -14,6 +14,10 @@ enum CssTokenType : byte /// Url, /// + /// A bad URL token, i.e. a url() that could not be parsed. + /// + BadUrl, + /// /// A color token. /// Color, diff --git a/src/AngleSharp.Css/Parser/CssTokenizer.cs b/src/AngleSharp.Css/Parser/CssTokenizer.cs index 47cd672..0d5a182 100644 --- a/src/AngleSharp.Css/Parser/CssTokenizer.cs +++ b/src/AngleSharp.Css/Parser/CssTokenizer.cs @@ -8,6 +8,7 @@ namespace AngleSharp.Css.Parser using AngleSharp.Text; using System; using System.Globalization; + using System.Text; /// /// The CSS tokenizer. @@ -74,6 +75,12 @@ public String ContentFrom(Int32 position) break; } + if ((current == 'u' || current == 'U') && !IsIdentContinuation(previous) && TryAppendUrl(sb, ref current, ref previous)) + { + trailingWhitespace = 0; + continue; + } + if ((current == Symbols.DoubleQuote || current == Symbols.SingleQuote) && previous != Symbols.ReverseSolidus) { trailingWhitespace = 0; @@ -137,6 +144,80 @@ public String ContentFrom(Int32 position) return sb.ToPool(); } + /// + /// Checks if the given character would continue an identifier, i.e. if a + /// following "url(" belongs to a longer function name such as "myurl(". + /// + private static Boolean IsIdentContinuation(Char current) => + current != Symbols.EndOfFile && (current.IsName() || current == Symbols.ReverseSolidus); + + /// + /// Appends a url token starting at the current position, if there is one. + /// The contents of an unquoted url token may contain ';', '{' and '}', + /// which must not be mistaken for the end of the surrounding value. + /// + private Boolean TryAppendUrl(StringBuilder sb, ref Char current, ref Char previous) + { + var start = Position; + var r = GetNext(); + var l = (r == 'r' || r == 'R') ? GetNext() : Symbols.EndOfFile; + var open = (l == 'l' || l == 'L') ? GetNext() : Symbols.EndOfFile; + + if (open != Symbols.RoundBracketOpen) + { + Back(Position - start); + return false; + } + + sb.Append(current).Append(r).Append(l).Append(open); + previous = open; + current = GetNext(); + + while (current.IsSpaceCharacter()) + { + sb.Append(current); + previous = current; + current = GetNext(); + } + + // A quoted url() is an ordinary function token; the string is handled by + // the caller. Only the unquoted form treats ';', '{' and '}' as content. + if (current == Symbols.DoubleQuote || current == Symbols.SingleQuote) + { + return true; + } + + while (current != Symbols.EndOfFile) + { + sb.Append(current); + + if (current == Symbols.RoundBracketClose) + { + previous = current; + current = GetNext(); + return true; + } + + if (current == Symbols.ReverseSolidus) + { + previous = current; + current = GetNext(); + + if (current == Symbols.EndOfFile) + { + break; + } + + sb.Append(current); + } + + previous = current; + current = GetNext(); + } + + return true; + } + internal void RaiseErrorOccurred(CssParseError error, TextPosition position) { Error?.Invoke(this, new CssErrorEvent(error, position)); @@ -1006,7 +1087,7 @@ private CssToken UrlStart() { case Symbols.EndOfFile: RaiseErrorOccurred(CssParseError.EOF); - return NewUrl(String.Empty, bad: true); + return NewUrl(String.Empty, bad: false); case Symbols.DoubleQuote: return UrlDQ(); @@ -1136,7 +1217,7 @@ private CssToken UrlUQ(Char current) } else if (current == Symbols.EndOfFile) { - return NewUrl(FlushBuffer(), bad: true); + return NewUrl(FlushBuffer(), bad: false); } else if (current is Symbols.DoubleQuote or Symbols.SingleQuote or Symbols.RoundBracketOpen || current.IsNonPrintable()) { @@ -1191,50 +1272,26 @@ private CssToken UrlEnd() private CssToken UrlBad() { var current = Current; - var curly = 0; - var round = 1; + // The remnants of a bad url are consumed so that parsing can resume + // after it, but they are not part of any value - they are discarded. while (current != Symbols.EndOfFile) { - if (current == Symbols.Semicolon) - { - Back(); - return NewUrl(FlushBuffer(), true); - } - else if (current == Symbols.CurlyBracketClose && --curly == -1) - { - Back(); - return NewUrl(FlushBuffer(), true); - } - else if (current == Symbols.RoundBracketClose && --round == 0) + if (current == Symbols.RoundBracketClose) { - StringBuffer.Append(current); - return NewUrl(FlushBuffer(), true); + break; } else if (IsValidEscape(current)) { current = GetNext(); - StringBuffer.Append(ConsumeEscape(current)); - } - else - { - if (current == Symbols.RoundBracketOpen) - { - ++round; - } - else if (curly == Symbols.CurlyBracketOpen) - { - ++curly; - } - - StringBuffer.Append(current); + ConsumeEscape(current); } current = GetNext(); } - RaiseErrorOccurred(CssParseError.EOF); - return NewUrl(FlushBuffer(), bad: true); + FlushBuffer(); + return NewUrl(String.Empty, bad: true); } /// @@ -1406,7 +1463,7 @@ private CssToken NewDimension(String data) private CssToken NewUrl(String data, Boolean bad = false) { - return new CssToken(CssTokenType.Url, data) { Position = _position }; + return new CssToken(bad ? CssTokenType.BadUrl : CssTokenType.Url, data) { Position = _position }; } private CssToken NewRange(String data) diff --git a/src/AngleSharp.Css/Parser/Micro/CssUriParser.cs b/src/AngleSharp.Css/Parser/Micro/CssUriParser.cs index 57b5223..083f218 100644 --- a/src/AngleSharp.Css/Parser/Micro/CssUriParser.cs +++ b/src/AngleSharp.Css/Parser/Micro/CssUriParser.cs @@ -16,18 +16,29 @@ public static class CssUriParser /// public static CssUrlValue ParseUri(this StringSource source) { + var start = source.Index; + if (source.IsFunction(FunctionNames.Url)) { var current = source.SkipSpacesAndComments(); - return current switch + var result = current switch { Symbols.DoubleQuote => DoubleQuoted(source), Symbols.SingleQuote => SingleQuoted(source), - Symbols.RoundBracketClose => new CssUrlValue(String.Empty), + Symbols.RoundBracketClose => Empty(source), Symbols.EndOfFile => new CssUrlValue(String.Empty), _ => Unquoted(source), }; + + if (result is null) + { + // A bad url yields no value at all. Nothing is consumed either, so + // that the caller sees the url() as unparsed instead of as absent. + source.BackTo(start); + } + + return result; } return null; @@ -43,7 +54,7 @@ private static CssUrlValue DoubleQuoted(StringSource source) if (current.IsLineBreak()) { - return Bad(source, buffer); + return Bad(buffer); } else if (Symbols.EndOfFile == current) { @@ -89,7 +100,7 @@ private static CssUrlValue SingleQuoted(StringSource source) if (current.IsLineBreak()) { - return Bad(source, buffer); + return Bad(buffer); } else if (current == Symbols.EndOfFile) { @@ -142,7 +153,7 @@ private static CssUrlValue Unquoted(StringSource source) } else if (current is Symbols.DoubleQuote or Symbols.SingleQuote or Symbols.RoundBracketOpen || current.IsNonPrintable()) { - return Bad(source, buffer); + return Bad(buffer); } else if (current != Symbols.ReverseSolidus) { @@ -154,7 +165,7 @@ private static CssUrlValue Unquoted(StringSource source) } else { - return Bad(source, buffer); + return Bad(buffer); } current = source.Next(); @@ -171,52 +182,19 @@ private static CssUrlValue End(StringSource source, StringBuilder buffer) return new CssUrlValue(buffer.ToPool()); } - return Bad(source, buffer); + return Bad(buffer); } - private static CssUrlValue Bad(StringSource source, StringBuilder buffer) + private static CssUrlValue Empty(StringSource source) { - var current = source.Current; - var curly = 0; - var round = 1; - - while (current != Symbols.EndOfFile) - { - if (current == Symbols.Semicolon) - { - return new CssUrlValue(buffer.ToPool()); - } - else if (current == Symbols.CurlyBracketClose && --curly == -1) - { - return new CssUrlValue(buffer.ToPool()); - } - else if (current == Symbols.RoundBracketClose && --round == 0) - { - source.Next(); - return new CssUrlValue(buffer.ToPool()); - } - else if (source.IsValidEscape()) - { - buffer.Append(source.ConsumeEscape()); - } - else - { - if (current == Symbols.RoundBracketOpen) - { - ++round; - } - else if (current == Symbols.CurlyBracketOpen) - { - ++curly; - } - - buffer.Append(current); - } + source.Next(); + return new CssUrlValue(String.Empty); + } - current = source.Next(); - } - - return new CssUrlValue(buffer.ToPool()); + private static CssUrlValue Bad(StringBuilder buffer) + { + buffer.ToPool(); + return null; } } }