diff --git a/src/ExCSS.Tests/PropertyTests/BackgroundProperty.cs b/src/ExCSS.Tests/PropertyTests/BackgroundProperty.cs index e9e3f3e..a738524 100644 --- a/src/ExCSS.Tests/PropertyTests/BackgroundProperty.cs +++ b/src/ExCSS.Tests/PropertyTests/BackgroundProperty.cs @@ -787,5 +787,32 @@ public void BackgroundThreeLayersExportCommaSeparatedLonghands() Assert.Equal("url(\"a.png\"), url(\"b.png\"), url(\"c.png\")", style.BackgroundImage); Assert.Equal("no-repeat, repeat-x, repeat", style.BackgroundRepeat); } + + [Theory] + // image-set()/cross-fade()/element() are valid values (CSS Images 4 2/3), now accepted by + // the shared ImageSourceConverter. + [InlineData("background-image: image-set(\"a.png\" 1x, \"b.png\" 2x)")] + [InlineData("background-image: image-set(url(a.png) 1x, url(b.png) 2dppx)")] + [InlineData("background-image: cross-fade(url(a.png), url(b.png), 50%)")] + [InlineData("background-image: cross-fade(50% url(a.png), url(b.png))")] + [InlineData("background-image: element(#hero)")] + public void BackgroundImageExtendedFunctionLegal(string snippet) + { + var property = ParseDeclaration(snippet); + Assert.IsType(property); + var concrete = (BackgroundImageProperty)property; + Assert.True(concrete.HasValue); + Assert.False(string.IsNullOrEmpty(concrete.Value)); + } + + [Theory] + [InlineData("background-image: image-set(banana)")] // option source is neither string nor image + [InlineData("background-image: element(.klass)")] // element() takes an id, not a class + [InlineData("background-image: cross-fade(5px)")] // neither image nor color + public void BackgroundImageMalformedExtendedFunctionIllegal(string snippet) + { + var property = ParseDeclaration(snippet); + Assert.False(((BackgroundImageProperty)property).HasValue); + } } } diff --git a/src/ExCSS.Tests/Tokenization.cs b/src/ExCSS.Tests/Tokenization.cs index dad4680..c7b8756 100644 --- a/src/ExCSS.Tests/Tokenization.cs +++ b/src/ExCSS.Tests/Tokenization.cs @@ -96,6 +96,33 @@ public void CssParserUnicodeRangeSelectedRangeIsExpandedOnDemand() Assert.Equal(new[] {"A", "B", "C"}, range.SelectedRange); } + [Fact] + public void ValueContextHash() + { + // In a value context '#' begins a (CSS Syntax 4.3.4): an all-hex name is a color + // literal, any other name stays an id hash-token (e.g. the '#id' inside element()). Previously a + // non-hex hash was truncated at the first non-hex char into an empty color plus a stray ident. + static void Check(string input, TokenType expectedType, string expectedData) + { + var lexer = new Lexer(new TextSource(input)) { IsInValue = true }; + var token = lexer.Get(); + Assert.Equal(expectedType, token.Type); + Assert.Equal(expectedData, token.Data); + Assert.Equal(TokenType.EndOfFile, lexer.Get().Type); + } + + Check("#f00", TokenType.Color, "f00"); + Check("#abc123", TokenType.Color, "abc123"); + Check("#deadbeef", TokenType.Color, "deadbeef"); + Check("#hero", TokenType.Hash, "hero"); + Check("#top", TokenType.Hash, "top"); + Check("#f00bar", TokenType.Hash, "f00bar"); + + // '#' not followed by a name code point is a plain '#' delimiter, not a hash-token. + var delim = new Lexer(new TextSource("# ")) { IsInValue = true }; + Assert.Equal(TokenType.Delim, delim.Get().Type); + } + [Fact] public void LexerOnlyCarriageReturn() { diff --git a/src/ExCSS/Enumerations/FunctionNames.cs b/src/ExCSS/Enumerations/FunctionNames.cs index afd8eb8..bcfccf2 100644 --- a/src/ExCSS/Enumerations/FunctionNames.cs +++ b/src/ExCSS/Enumerations/FunctionNames.cs @@ -21,6 +21,9 @@ public static class FunctionNames public static readonly string RepeatingLinearGradient = "repeating-linear-gradient"; public static readonly string RepeatingRadialGradient = "repeating-radial-gradient"; public static readonly string Image = "image"; + public static readonly string ImageSet = "image-set"; + public static readonly string CrossFade = "cross-fade"; + public static readonly string Element = "element"; public static readonly string Counter = "counter"; public static readonly string Counters = "counters"; public static readonly string Content = "content"; diff --git a/src/ExCSS/Model/Converters.cs b/src/ExCSS/Model/Converters.cs index 0cd8c44..04c0dd8 100644 --- a/src/ExCSS/Model/Converters.cs +++ b/src/ExCSS/Model/Converters.cs @@ -443,7 +443,17 @@ public static readonly IValueConverter ColorConverter.WithCurrentColor().Option(Color.Black)); public static readonly IValueConverter MultipleShadowConverter = ShadowConverter.FromList().OrNone(); - public static readonly IValueConverter ImageSourceConverter = UrlConverter.Or(GradientConverter); + // The CSS Images 4 image functions (image-set(), cross-fade(), element()). Composed into + // ImageSourceConverter so every property accepts them. + public static readonly IValueConverter ImageSetImageConverter = + Construct(() => new FunctionValueConverter(FunctionNames.ImageSet, new ImageSetConverter())); + public static readonly IValueConverter CrossFadeImageConverter = + Construct(() => new FunctionValueConverter(FunctionNames.CrossFade, new CrossFadeConverter())); + public static readonly IValueConverter ElementImageConverter = + Construct(() => new FunctionValueConverter(FunctionNames.Element, new ElementImageConverter())); + + public static readonly IValueConverter ImageSourceConverter = UrlConverter.Or(GradientConverter) + .Or(ImageSetImageConverter).Or(CrossFadeImageConverter).Or(ElementImageConverter); public static readonly IValueConverter OptionalImageSourceConverter = ImageSourceConverter.OrNone(); public static readonly IValueConverter MultipleImageSourceConverter = OptionalImageSourceConverter.FromList(); public static readonly IValueConverter BorderRadiusShorthandConverter = new BorderRadiusConverter(); diff --git a/src/ExCSS/Model/ValueBuilder.cs b/src/ExCSS/Model/ValueBuilder.cs index cb3bcb2..96de355 100644 --- a/src/ExCSS/Model/ValueBuilder.cs +++ b/src/ExCSS/Model/ValueBuilder.cs @@ -50,6 +50,7 @@ public void Apply(Token token) case TokenType.Dimension: case TokenType.Percentage: case TokenType.Color: + case TokenType.Hash: case TokenType.Delim: case TokenType.String: case TokenType.Url: diff --git a/src/ExCSS/Parser/Lexer.cs b/src/ExCSS/Parser/Lexer.cs index e7ffdbe..7a8df52 100644 --- a/src/ExCSS/Parser/Lexer.cs +++ b/src/ExCSS/Parser/Lexer.cs @@ -316,14 +316,44 @@ private Token StringSingleQuote() private Token ColorLiteral() { var current = GetNext(); - while (current.IsHex()) + + // '#' not followed by a name code point or a valid escape is a plain delimiter (CSS Syntax 4.3.4). + if (!current.IsName() && !IsValidEscape(current)) { - StringBuffer.Append(current); - current = GetNext(); + Back(); + return NewDelimiter(Symbols.Num); } Back(); - return NewColor(FlushBuffer()); + + // A '#' always begins a , consuming a whole (CSS Syntax 4.3.4). Classify it as + // a color literal only when the name is entirely hex digits (e.g. "#f00"); otherwise keep it as an + // id hash-token (e.g. "#hero", the id inside element()), instead of truncating at the first + // non-hex character - which turned "#hero" into an empty color plus a stray "hero" ident. + var allHex = true; + + while (true) + { + current = GetNext(); + + if (current.IsName()) + { + allHex = allHex && current.IsHex(); + StringBuffer.Append(current); + } + else if (IsValidEscape(current)) + { + current = GetNext(); + StringBuffer.Append(ConsumeEscape(current)); + allHex = false; + } + else + { + Back(); + var text = FlushBuffer(); + return allHex ? NewColor(text) : NewHash(text); + } + } } private Token HashStart() diff --git a/src/ExCSS/ValueConverters/ExtendedImageConverters.cs b/src/ExCSS/ValueConverters/ExtendedImageConverters.cs new file mode 100644 index 0000000..94d3357 --- /dev/null +++ b/src/ExCSS/ValueConverters/ExtendedImageConverters.cs @@ -0,0 +1,137 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace ExCSS +{ + using static Converters; + + /// + /// The value of an image-set()/cross-fade()/element() function (CSS Images 4 §2/§3). + /// These are validated as syntactically-valid <image> values; the original tokens are kept + /// for serialization. + /// + internal sealed class ImageFunctionValue : IPropertyValue + { + public ImageFunctionValue(IEnumerable tokens) + { + Original = new TokenValue(tokens); + } + + public string CssText => Original.Text; + + public TokenValue Original { get; } + + public TokenValue ExtractFor(string name) => Original; + } + + // The inner of an image-set()/cross-fade() option - a url() or a gradient. Referencing the two + // base converters directly (rather than ImageSourceConverter) avoids a static-init cycle. + internal static class ExtendedImage + { + // Qualify GradientConverter - the bare name also resolves to the abstract GradientConverter type. + public static readonly IValueConverter InnerImage = UrlConverter.Or(Converters.GradientConverter); + } + + // element( ) - a single '#id' hash argument. + internal sealed class ElementImageConverter : IValueConverter + { + public IPropertyValue Convert(IEnumerable value) + { + var tokens = value.Where(t => t.Type != TokenType.Whitespace).ToArray(); + + // A '#id' is a single : an all-hex id lexes as a Color token, any other as a Hash. + var isSingleHash = tokens.Length == 1 && + (tokens[0].Type == TokenType.Hash || tokens[0].Type == TokenType.Color); + + return isSingleHash ? new ImageFunctionValue(value) : null; + } + + public IPropertyValue Construct(Property[] properties) => properties.Guard(); + } + + // image-set( # ), + // option = [ | ] [ || type() ]? + internal sealed class ImageSetConverter : IValueConverter + { + public IPropertyValue Convert(IEnumerable value) + { + var options = value.ToList(); + if (options.Count == 0 || options.Any(o => !IsOption(o))) return null; + return new ImageFunctionValue(value); + } + + private static bool IsOption(List option) + { + var items = option.ToItems(); + if (items.Count == 0) return false; + + // The source: a or a url()/gradient . + if (StringConverter.Convert(items[0]) == null && ExtendedImage.InnerImage.Convert(items[0]) == null) + return false; + + // Any remaining items are a (including the `x` dppx alias) and/or type(). + for (var i = 1; i < items.Count; i++) + { + if (ResolutionConverter.Convert(items[i]) == null && !IsTypeFunction(items[i])) + return false; + } + + return true; + } + + private static bool IsTypeFunction(List item) + { + if (item.Count != 1 || item[0] is not FunctionToken function || + !function.Data.Equals("type", StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + var inner = function.Where(t => + t.Type != TokenType.Whitespace && t.Type != TokenType.RoundBracketClose).ToArray(); + + return inner.Length == 1 && inner[0].Type == TokenType.String; + } + + public IPropertyValue Construct(Property[] properties) => properties.Guard(); + } + + // cross-fade( # ), = ? && [ | ]; plus the legacy + // cross-fade( , , ) form. + internal sealed class CrossFadeConverter : IValueConverter + { + public IPropertyValue Convert(IEnumerable value) + { + var args = value.ToList(); + if (args.Count == 0) return null; + + var isModern = args.All(IsCrossFadeImage); + var isLegacy = args.Count == 3 && IsCrossFadeImage(args[0]) && IsCrossFadeImage(args[1]) && + PercentConverter.Convert(args[2]) != null; + + return isModern || isLegacy ? new ImageFunctionValue(value) : null; + } + + private static bool IsCrossFadeImage(List arg) + { + var hasImageOrColor = false; + + foreach (var item in arg.ToItems()) + { + if (ExtendedImage.InnerImage.Convert(item) != null || ColorConverter.Convert(item) != null) + { + hasImageOrColor = true; + } + else if (PercentConverter.Convert(item) == null) + { + return false; + } + } + + return hasImageOrColor; + } + + public IPropertyValue Construct(Property[] properties) => properties.Guard(); + } +} diff --git a/src/ExCSS/Values/Resolution.cs b/src/ExCSS/Values/Resolution.cs index 2977ecb..a2550ed 100644 --- a/src/ExCSS/Values/Resolution.cs +++ b/src/ExCSS/Values/Resolution.cs @@ -48,6 +48,7 @@ public static Unit GetUnit(string s) "dpcm" => Unit.Dpcm, "dpi" => Unit.Dpi, "dppx" => Unit.Dppx, + "x" => Unit.Dppx, // `x` is the canonical alias for `dppx` (CSS Values 4 7.4) _ => Unit.None }; }