From 2eddd84def30503e1eb6e637553ec051f4981eba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?G=C3=A9rald=20Barr=C3=A9?= Date: Fri, 4 Sep 2026 20:50:40 -0400 Subject: [PATCH 1/2] Fix truncation of unquoted url() values containing ; { or } CssTokenizer.ContentFrom re-scans the raw source to recover a declaration value or an at-rule prelude, and breaks at the first ';', '{' or '}'. It special-cased quoted strings but knew nothing about url tokens, where all three characters are legal content. As a result "url(data:image/svg+xml;base64,...)" was cut at the first semicolon and became url("data:image/svg+xml"); the remainder failed to re-tokenize as a declaration and was dropped, so a round-trip through CssText silently destroyed the asset. Unquoted data URIs are emitted by every major bundler, so this affected many real stylesheets. Via the GetArgument path the same flaw discarded whole rules: a @supports condition containing url(a;b) lost its entire rule. Teach ContentFrom about url tokens: on an ident "url" immediately followed by '(', consume through the matching unescaped ')' before the break check resumes. Behaviour was validated against Chrome's CSSOM over ~40 inputs, which pinned down three subtleties: - The ident must be immediately followed by '(' - "myurl(", "-url(" and "url\t(" are ordinary function tokens where ';' does terminate the declaration, so a plain substring match would introduce a new bug. - url( followed by a quote is a function token, not a url token: the string wins and a ')' inside it does not close the url. The scan skips whitespace after '(' and defers to the existing string handling. - Bad-url cases such as url(a b) still consume through the matching ')', so a single "consume to unescaped ')'" rule extracts the correct span in every case. Escapes inside the url are honoured, so url(a\;b) also parses correctly where it previously produced url("a\\"). The remaining divergences from Chrome (url(a b), url(a(b), url()) live in UrlUQ/UrlBad and are unchanged by this commit. --- src/AngleSharp.Css.Tests/Styling/CssSheet.cs | 107 +++++++++++++++++++ src/AngleSharp.Css/Parser/CssTokenizer.cs | 81 ++++++++++++++ 2 files changed, 188 insertions(+) diff --git a/src/AngleSharp.Css.Tests/Styling/CssSheet.cs b/src/AngleSharp.Css.Tests/Styling/CssSheet.cs index cf2d448a..2a621a6b 100644 --- a/src/AngleSharp.Css.Tests/Styling/CssSheet.cs +++ b/src/AngleSharp.Css.Tests/Styling/CssSheet.cs @@ -767,6 +767,113 @@ 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 CssSheetFromStreamWeirdBytesLeadingToInfiniteLoop() { diff --git a/src/AngleSharp.Css/Parser/CssTokenizer.cs b/src/AngleSharp.Css/Parser/CssTokenizer.cs index 47cd672c..963f8e27 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)); From e53aba1267b3fc5fc3113075846f354db154f70e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?G=C3=A9rald=20Barr=C3=A9?= Date: Fri, 4 Sep 2026 21:07:08 -0400 Subject: [PATCH 2/2] Make bad url() handling match the spec and browsers My previous commit fixed where a url token *ends*. This fixes what happens when one is invalid, which was a separate defect with the same symptom class: values that browsers reject were being accepted as garbage. Three divergences from Chrome, all rooted in the fact that a bad url had no way to be reported as a failure: CssUriParser.Bad() returned a CssUrlValue built from whatever characters it had scanned past, so an invalid url produced a plausible-looking but wrong value instead of failing. url(a b) became url("ab") and url(a(b) became url("a(b)"); browsers drop the declaration in both cases. Bad() now returns null and ParseUri rewinds the source, so the url() is seen as unparsed rather than as absent - rewinding matters, because merely consuming the bad url let "background: url(a b) red" silently re-parse as "background: red" instead of being dropped. ParseUri did not consume the ')' of an empty url(), leaving the source mid-value so the declaration was rejected. url() is valid and means the empty URL, so it now parses as url(""). CssTokenizer.NewUrl accepted a "bad" parameter and ignored it, so no bad-url token could ever exist at the sheet level and UrlBad's scanned-over characters became the url's content. "@import url(a b)" imported the garbage href "a b)". A BadUrl token type now carries the distinction, and UrlBad discards the remnants it consumes. Per the spec, EOF ends a url token rather than invalidating it, so the two EOF paths that flagged bad no longer do - "@import url(abc" still imports "abc". Consequences at the rule level, matching Chrome: - @import with a bad url is dropped instead of importing a garbage href. - @namespace with a bad url is dropped. Fixing this forced a decision on the string form, since one condition governs both: @namespace accepted only a url token, so "@namespace x "http://foo"" silently produced an empty namespace URI. It now accepts a string as the spec requires. Behaviour was verified against Chrome's CSSOM over the full 30-case matrix (both fixes together); no divergence remains. ParseInlineStyleWithToleratedInvalidValueShouldReturnThatValue asserted the old lenient recovery for url(javascript:alert(1)) - an unquoted url with a '(' in it, i.e. exactly the url(a(b) case. Chrome drops that declaration, so the test now documents that, and a companion test covers the quoted form which is valid and still round-trips. The tolerance it relied on came from Bad(), not from IsIncludingUnknownDeclarations, which only governs unknown property names. --- src/AngleSharp.Css.Tests/Styling/CssSheet.cs | 112 ++++++++++++++++++ .../Values/ErrorHandling.cs | 17 ++- src/AngleSharp.Css/Parser/CssBuilder.cs | 8 +- src/AngleSharp.Css/Parser/CssTokenType.cs | 4 + src/AngleSharp.Css/Parser/CssTokenizer.cs | 44 ++----- .../Parser/Micro/CssUriParser.cs | 74 ++++-------- 6 files changed, 174 insertions(+), 85 deletions(-) diff --git a/src/AngleSharp.Css.Tests/Styling/CssSheet.cs b/src/AngleSharp.Css.Tests/Styling/CssSheet.cs index 2a621a6b..eb3a6f6f 100644 --- a/src/AngleSharp.Css.Tests/Styling/CssSheet.cs +++ b/src/AngleSharp.Css.Tests/Styling/CssSheet.cs @@ -874,6 +874,118 @@ public void CssSheetWithFunctionEndingInUrlIsNotAUrlToken() 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 4cfcbd21..7fff3936 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 344369cd..52ad785e 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 e3d28221..82d85e23 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 963f8e27..0d5a182e 100644 --- a/src/AngleSharp.Css/Parser/CssTokenizer.cs +++ b/src/AngleSharp.Css/Parser/CssTokenizer.cs @@ -1087,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(); @@ -1217,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()) { @@ -1272,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); } /// @@ -1487,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 57b52236..083f2180 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; } } }