Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions src/ExCSS.Tests/PropertyTests/BackgroundProperty.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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 <image> 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<BackgroundImageProperty>(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);
}
}
}
27 changes: 27 additions & 0 deletions src/ExCSS.Tests/Tokenization.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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 <hash-token> (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()
{
Expand Down
3 changes: 3 additions & 0 deletions src/ExCSS/Enumerations/FunctionNames.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
12 changes: 11 additions & 1 deletion src/ExCSS/Model/Converters.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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 <image> 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();
Expand Down
1 change: 1 addition & 0 deletions src/ExCSS/Model/ValueBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
38 changes: 34 additions & 4 deletions src/ExCSS/Parser/Lexer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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 <hash-token>, consuming a whole <name> (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()
Expand Down
137 changes: 137 additions & 0 deletions src/ExCSS/ValueConverters/ExtendedImageConverters.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
using System;
using System.Collections.Generic;
using System.Linq;

namespace ExCSS
{
using static Converters;

/// <summary>
/// The value of an <c>image-set()</c>/<c>cross-fade()</c>/<c>element()</c> function (CSS Images 4 §2/§3).
/// These are validated as syntactically-valid <c>&lt;image&gt;</c> values; the original tokens are kept
/// for serialization.
/// </summary>
internal sealed class ImageFunctionValue : IPropertyValue
{
public ImageFunctionValue(IEnumerable<Token> tokens)
{
Original = new TokenValue(tokens);
}

public string CssText => Original.Text;

public TokenValue Original { get; }

public TokenValue ExtractFor(string name) => Original;
}

// The inner <image> 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( <id-selector> ) - a single '#id' hash argument.
internal sealed class ElementImageConverter : IValueConverter
{
public IPropertyValue Convert(IEnumerable<Token> value)
{
var tokens = value.Where(t => t.Type != TokenType.Whitespace).ToArray();

// A '#id' is a single <hash-token>: 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<ImageFunctionValue>();
}

// image-set( <image-set-option># ),
// option = [ <image> | <string> ] [ <resolution> || type(<string>) ]?
internal sealed class ImageSetConverter : IValueConverter
{
public IPropertyValue Convert(IEnumerable<Token> 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<Token> option)
{
var items = option.ToItems();
if (items.Count == 0) return false;

// The source: a <string> or a url()/gradient <image>.
if (StringConverter.Convert(items[0]) == null && ExtendedImage.InnerImage.Convert(items[0]) == null)
return false;

// Any remaining items are a <resolution> (including the `x` dppx alias) and/or type(<string>).
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<Token> 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<ImageFunctionValue>();
}

// cross-fade( <cf-image># ), <cf-image> = <percentage>? && [ <image> | <color> ]; plus the legacy
// cross-fade( <image>, <image>, <percentage> ) form.
internal sealed class CrossFadeConverter : IValueConverter
{
public IPropertyValue Convert(IEnumerable<Token> 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<Token> 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<ImageFunctionValue>();
}
}
1 change: 1 addition & 0 deletions src/ExCSS/Values/Resolution.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
};
}
Expand Down