using AngleSharp.Dom;
using AngleSharp.Html.Dom;
using AngleSharp.Css.Dom;
using AngleSharp.Css.Parser;
using AngleSharp.Css;
using AngleSharp;
namespace XFESystem.Browser;
/// <summary>Bounded author cascade. Original implementation of the supported CSS subset; no dynamic compiler.</summary>
internal sealed class BrowserCssCascade
{
private readonly BrowserCssLimits limits;
private BrowserViewport viewport, parsedViewport;
private List<string> diagnostics;
private bool blockAuthorStyles, validSources;
private readonly InteractionHolder interaction = new();
private ICssSelectorParser selectorParser;
public BrowserCssCascade(BrowserCssLimits limits, BrowserViewport viewport, List<string> diagnostics, bool blockAuthorStyles = false,
BrowserInteractionState? interaction = null)
{
this.limits = limits; this.viewport = viewport; this.diagnostics = diagnostics; this.blockAuthorStyles = blockAuthorStyles;
this.interaction.Value = interaction ?? new(); selectorParser = CreateParser(this.interaction);
}
private sealed record Rule(string Selector, ISelector Matcher, int Specificity, List<Declaration> Declarations, int Order, string Pseudo = "");
private readonly record struct Declaration(string Name, string Value, bool Important);
private readonly record struct Winner(bool Important, bool Inline, int Specificity, long Order, string Value);
private readonly List<Rule> rules = [];
private readonly List<Rule> generalRules = [];
private readonly Dictionary<string, List<Rule>> classRules = new(StringComparer.OrdinalIgnoreCase);
private IEnumerable<Rule> Candidates(IElement element)
{
foreach (var rule in generalRules) yield return rule;
foreach (string name in element.ClassList)
if (classRules.TryGetValue(name, out var matching)) foreach (var rule in matching) yield return rule;
}
private void AddRule(Rule rule)
{
rules.Add(rule);
string selector = rule.Selector;
// Only a single unescaped ASCII class can enter the index. All complex
// selectors keep the full matcher, including :is/:not and combinators.
if (selector.Length > 1 && selector[0] == '.' &&
selector.AsSpan(1).IndexOfAnyExcept("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-".AsSpan()) < 0)
{
string name = selector[1..];
if (!classRules.TryGetValue(name, out var matching)) classRules[name] = matching = [];
matching.Add(rule);
}
else generalRules.Add(rule);
}
private int characters, declarations, comparisons, order;
private int parsedDeclarations, parsedOrder;
private readonly List<string> sources = [], currentSources = [], parseMessages = [], textSegments = [];
private readonly HashSet<IElement> styleOwners = new(ReferenceEqualityComparer.Instance);
private readonly Dictionary<IElement, ComplexTextSnapshot> complexTexts = new(ReferenceEqualityComparer.Instance);
private readonly List<IElement> removedOwners = [];
private sealed record ComplexTextSnapshot(string[] Segments, string Text);
private sealed class InteractionHolder { public BrowserInteractionState Value = new(); }
public long ParseGeneration { get; private set; }
public long ReuseCount { get; private set; }
internal bool InteractionChangesStyles(BrowserInteractionState before, BrowserInteractionState after, IEnumerable<IElement> elements)
{
var relevant = rules.Where(rule => rule.Selector.Contains(":hover", StringComparison.OrdinalIgnoreCase) ||
rule.Selector.Contains(":active", StringComparison.OrdinalIgnoreCase) || rule.Selector.Contains(":focus", StringComparison.OrdinalIgnoreCase)).ToArray();
if (relevant.Length == 0) return false;
var saved = interaction.Value;
bool changed = false; int work = 0;
try
{
foreach (var element in elements)
{
foreach (var rule in relevant)
{
if (++work > limits.MaximumSelectorEvaluations) { changed = true; break; }
interaction.Value = before; bool first = rule.Matcher.Match(element);
interaction.Value = after; bool second = rule.Matcher.Match(element);
if (first != second) { changed = true; break; }
}
if (changed) break;
}
}
catch { interaction.Value = saved; return true; }
interaction.Value = saved;
return changed;
}
private readonly HashSet<string> messages = new(StringComparer.Ordinal);
private static ICssSelectorParser CreateParser(InteractionHolder state)
{
var factory = new DefaultPseudoClassSelectorFactory();
foreach (string name in new[] { "hover", "active", "focus", "focus-within", "focus-visible" })
{ factory.Unregister(name); factory.Register(name, new StateSelector(name, state)); }
var services = Configuration.Default.Services.Where(service => service is not IPseudoClassSelectorFactory).Append(factory);
return BrowsingContext.New(new Configuration(services)).GetService<ICssSelectorParser>()!;
}
private sealed class StateSelector(string name, InteractionHolder state) : ISelector
{
public string Text => ":" + name;
public Priority Specificity => Priority.OneClass;
public bool Match(IElement element, IElement? scope)
{
var current = state.Value;
INode? target = name switch { "hover" => current.Hovered, "active" => current.Active, _ => current.Focus };
if (name is "focus" or "focus-visible") return ReferenceEquals(element, target);
while (target is not null) { if (ReferenceEquals(element, target)) return true; target = target.Parent; }
return false;
}
public void Accept(ISelectorVisitor visitor) => visitor.PseudoClass(name);
}
private static readonly HashSet<string> Inherited = new(StringComparer.OrdinalIgnoreCase)
{ "color", "font-size", "font-family", "font-weight", "font-style", "line-height", "white-space", "visibility", "text-align", "cursor", "letter-spacing", "pointer-events", "list-style-type", "list-style-position", "border-collapse", "border-spacing", "overflow-wrap", "word-break" };
private static readonly HashSet<string> Supported = new(StringComparer.OrdinalIgnoreCase)
{
"display", "position", "visibility", "overflow", "overflow-x", "overflow-y", "pointer-events", "opacity", "z-index", "transform",
"width", "height", "min-width", "max-width", "min-height", "max-height", "left", "right", "top", "bottom", "box-sizing",
"margin", "padding", "border", "border-width", "border-color", "border-style", "background", "background-color", "color",
"font", "font-family", "font-size", "font-weight", "font-style", "line-height", "white-space", "text-align", "text-decoration", "cursor", "letter-spacing",
"list-style-type", "list-style-position", "list-style", "border-collapse", "border-spacing", "overflow-wrap", "word-break", "box-shadow", "background-image", "vertical-align", "text-transform", "content",
"flex", "flex-grow", "flex-shrink", "flex-basis", "flex-direction", "flex-wrap", "justify-content", "align-items", "align-self", "gap", "row-gap", "column-gap", "order"
};
public void Load(IDocument document, IReadOnlyList<BrowserStyleSheet>? linked)
{
currentSources.Clear(); styleOwners.Clear(); int sourceCharacters = 0;
void Source(string text)
{
if (text.Length > limits.MaximumCssCharacters - sourceCharacters) throw new BrowserLimitException("CSS character limit reached.");
if (currentSources.Count >= limits.MaximumNodes) throw new BrowserLimitException("CSS source count limit reached.");
sourceCharacters += text.Length; currentSources.Add(text);
}
if (!blockAuthorStyles)
{
foreach (var element in document.QuerySelectorAll("style,link"))
{
if (element.LocalName == "style")
{ styleOwners.Add(element); Source(StyleText(element, limits.MaximumCssCharacters - sourceCharacters)); }
else if (linked is not null)
foreach (var sheet in linked)
if (ReferenceEquals(sheet.Owner, element)) Source(sheet.CssText);
}
if (linked is not null) foreach (var sheet in linked) if (sheet.Owner is null) Source(sheet.CssText);
}
removedOwners.Clear();
foreach (var owner in complexTexts.Keys) if (!styleOwners.Contains(owner)) removedOwners.Add(owner);
foreach (var owner in removedOwners) complexTexts.Remove(owner);
bool reuse = validSources && parsedViewport.Width == viewport.Width && parsedViewport.Height == viewport.Height &&
sources.Count == currentSources.Count;
if (reuse)
for (int index = 0; index < sources.Count; index++)
if (!string.Equals(sources[index], currentSources[index], StringComparison.Ordinal)) { reuse = false; break; }
if (reuse)
{
// Counters are per layout, including inline declarations. Reusing
// compiled rules never avoids any configured source/output budget.
characters = sourceCharacters; declarations = parsedDeclarations; order = parsedOrder;
if (characters > limits.MaximumCssCharacters || declarations > limits.MaximumDeclarations || rules.Count > limits.MaximumRules)
throw new BrowserLimitException("Cached CSS exceeds the configured layout budget.");
foreach (string message in parseMessages) Note(message);
ReuseCount++;
}
else
{
validSources = false; rules.Clear(); generalRules.Clear(); classRules.Clear(); sources.Clear(); parseMessages.Clear(); characters = declarations = order = 0;
foreach (string source in currentSources) ParseSheet(source);
parsedDeclarations = declarations; parsedOrder = order; parsedViewport = viewport;
sources.AddRange(currentSources); parseMessages.AddRange(messages); validSources = true; ParseGeneration++;
}
if (blockAuthorStyles) Note("Author styles disabled by the browser's conservative CSP profile");
}
public void BeginLayout(BrowserViewport viewport, List<string> diagnostics, bool blockAuthorStyles, BrowserInteractionState state)
{
this.viewport = viewport; this.diagnostics = diagnostics; this.blockAuthorStyles = blockAuthorStyles;
interaction.Value = state; comparisons = 0; messages.Clear();
}
public void ResetDocument()
{
validSources = false; rules.Clear(); generalRules.Clear(); classRules.Clear(); sources.Clear(); currentSources.Clear(); parseMessages.Clear(); complexTexts.Clear();
styleOwners.Clear(); removedOwners.Clear(); textSegments.Clear(); messages.Clear(); interaction.Value = new();
selectorParser = CreateParser(interaction); characters = declarations = comparisons = order = parsedDeclarations = parsedOrder = 0;
}
private string StyleText(IElement owner, int maximum)
{
var children = owner.ChildNodes;
if (children.Length == 0) { complexTexts.Remove(owner); return ""; }
if (children.Length == 1 && children[0] is IText text)
{ complexTexts.Remove(owner); return text.Data; }
return ComplexStyleText(owner, maximum);
}
private string ComplexStyleText(IElement owner, int maximum)
{
textSegments.Clear(); int characters = 0, nodes = 0;
void Read(INode node, int depth)
{
if (++nodes > limits.MaximumNodes || depth > limits.MaximumDepth) throw new BrowserLimitException("CSS style text DOM bound reached.");
if (node is IText text)
{
string data = text.Data;
if (data.Length > maximum - characters) throw new BrowserLimitException("CSS character limit reached.");
characters += data.Length; textSegments.Add(data);
}
else foreach (var child in node.ChildNodes) Read(child, depth + 1);
}
foreach (var child in owner.ChildNodes) Read(child, 1);
if (complexTexts.TryGetValue(owner, out var snapshot) && snapshot.Segments.Length == textSegments.Count)
{
bool unchanged = true;
for (int index = 0; index < textSegments.Count; index++)
if (!string.Equals(snapshot.Segments[index], textSegments[index], StringComparison.Ordinal)) { unchanged = false; break; }
if (unchanged) return snapshot.Text;
}
string result = string.Concat(textSegments);
complexTexts[owner] = new(textSegments.ToArray(), result);
return result;
}
public CssComputedStyle Compute(IElement element, CssComputedStyle? parent)
{
var values = Defaults(element);
bool initialColor = values.ContainsKey("color");
bool initialFontSize = values.ContainsKey("font-size");
if (parent is not null)
{
foreach (var pair in parent.Properties)
if ((Inherited.Contains(pair.Key) || pair.Key.StartsWith("--", StringComparison.Ordinal)) && !values.ContainsKey(pair.Key)) values[pair.Key] = pair.Value;
if (!initialFontSize) values["font-size"] = CssValue.Pixels(parent.Font.Size);
if (values.TryGetValue("line-height", out string? inheritedLine) && inheritedLine != "normal" &&
!double.IsFinite(CssValue.Number(inheritedLine, double.NaN))) values["line-height"] = CssValue.Pixels(parent.LineHeight);
if (!initialColor) values["color"] = CssValue.SerializeColor(parent.Color);
}
var winning = new Dictionary<string, Winner>(StringComparer.OrdinalIgnoreCase);
foreach (var rule in Candidates(element))
{
if (rule.Pseudo.Length!=0) continue;
if (++comparisons > limits.MaximumSelectorEvaluations) throw new BrowserLimitException("CSS selector evaluation limit reached.");
bool matches;
try { matches = rule.Matcher.Match(element, element.Owner?.DocumentElement ?? element); }
catch (Exception error) when (error is not OutOfMemoryException and not StackOverflowException)
{ Note("Unsupported selector: " + rule.Selector); continue; }
if (matches) Apply(rule.Declarations, false, rule.Specificity, rule.Order, winning);
}
string? inline = element.GetAttribute("style");
if (!blockAuthorStyles && !string.IsNullOrWhiteSpace(inline)) Apply(ParseDeclarations(inline), true, 0, ++order, winning);
foreach (var pair in winning)
{
string value = pair.Value.Value;
if (value is "inherit" or "unset" && (value == "inherit" || Inherited.Contains(pair.Key)))
value = parent is null ? "initial" : pair.Key == "font-size" ? CssValue.Pixels(parent.Font.Size) :
pair.Key == "line-height" && !double.IsFinite(CssValue.Number(parent.Get("line-height", "normal"), double.NaN)) && parent.Get("line-height", "normal") != "normal" ? CssValue.Pixels(parent.LineHeight) : parent.GetPropertyValue(pair.Key);
if (value == "initial" || value == "unset") values.Remove(pair.Key);
else values[pair.Key] = value;
}
foreach (string key in values.Keys.ToArray())
{
string? resolved = Variables(values[key], values, 0);
if (resolved is null) values.Remove(key); else values[key] = resolved;
}
return new(values, parent, viewport);
}
public CssComputedStyle ComputeMarker(IElement element, CssComputedStyle style)=>ComputePseudo(element,"marker",style)??style;
public CssComputedStyle? ComputePseudo(IElement element,string pseudo,CssComputedStyle style)
{
// Most elements have no generated content. Test the bounded, indexed
// candidates before copying the inherited custom-property environment;
// the website's theme can otherwise be copied twice for every element.
Dictionary<string, Winner>? winning = null;
foreach (var rule in Candidates(element))
{
if (rule.Pseudo!=pseudo) continue;
if (++comparisons > limits.MaximumSelectorEvaluations) throw new BrowserLimitException("CSS selector evaluation limit reached.");
if (rule.Matcher.Match(element, element.Owner?.DocumentElement ?? element))
{ winning ??= new(StringComparer.OrdinalIgnoreCase); Apply(rule.Declarations, false, rule.Specificity, rule.Order, winning); }
}
if (winning is null) return pseudo=="marker"?style:null;
// A universal reset can match ::before/::after without generating one.
// Content is not inherited into this values map, so without a winning
// content declaration the result below would always be "normal"/null.
if (pseudo!="marker" && !winning.ContainsKey("content")) return null;
var values = pseudo=="marker"?new Dictionary<string, string>(style.Properties, StringComparer.OrdinalIgnoreCase):
style.Properties.Where(pair=>Inherited.Contains(pair.Key)||pair.Key.StartsWith("--",StringComparison.Ordinal)).ToDictionary(pair=>pair.Key,pair=>pair.Value,StringComparer.OrdinalIgnoreCase);
if(pseudo!="marker")values["display"]="inline";
values["font-size"] = CssValue.Pixels(style.Font.Size);
if(values.TryGetValue("line-height",out string? line)&&line!="normal"&&!double.IsFinite(CssValue.Number(line,double.NaN)))values["line-height"]=CssValue.Pixels(style.LineHeight);
foreach (var pair in winning)
{
string value=pair.Value.Value;
if(value is "inherit" or "unset" &&(value=="inherit"||Inherited.Contains(pair.Key)))value=pair.Key=="font-size"?CssValue.Pixels(style.Font.Size):style.GetPropertyValue(pair.Key);
if(value is "initial" or "unset")values.Remove(pair.Key);else values[pair.Key]=value;
}
foreach (string key in values.Keys.ToArray())
{ string? resolved = Variables(values[key], values, 0); if (resolved is null) values.Remove(key); else values[key] = resolved; }
if(pseudo!="marker"&&values.GetValueOrDefault("content","normal") is "normal" or "none")return null;
return new(values, style, viewport);
}
private void Apply(List<Declaration> source, bool inline, int specificity, int ruleOrder, Dictionary<string, Winner> winners)
{
int index = 0;
foreach (var item in source)
{
var candidate = new Winner(item.Important, inline, specificity, ((long)ruleOrder << 32) + index++, item.Value);
if (!winners.TryGetValue(item.Name, out var old) || Greater(candidate, old)) winners[item.Name] = candidate;
}
}
private static bool Greater(Winner a, Winner b) => a.Important != b.Important ? a.Important : a.Inline != b.Inline ? a.Inline :
a.Specificity != b.Specificity ? a.Specificity > b.Specificity : a.Order >= b.Order;
private void ParseSheet(string css, int depth = 0, bool countCharacters = true)
{
if (depth > 32) throw new BrowserLimitException("CSS nested rule depth limit reached.");
if (countCharacters) characters += css.Length;
if (characters > limits.MaximumCssCharacters) throw new BrowserLimitException("CSS character limit reached.");
for (int offset = 0; offset < css.Length;)
{
while (offset < css.Length && char.IsWhiteSpace(css[offset])) offset++;
int open = Find(css, offset, '{');
int semicolon = Find(css, offset, ';');
if (open < 0) break;
if (semicolon >= 0 && semicolon < open) { Note("Unsupported CSS at-rule: " + WithoutComments(css,offset,semicolon-offset)); offset = semicolon + 1; continue; }
int close = Matching(css, open, '{', '}');
if (close < 0) { Note("Malformed CSS block ignored"); break; }
string selector = WithoutComments(css,offset,open-offset).Trim(); string body = css[(open + 1)..close]; offset = close + 1;
if (selector.StartsWith('@'))
{
if (selector.StartsWith("@media", StringComparison.OrdinalIgnoreCase))
{
if (MediaMatches(selector[6..])) ParseSheet(body, depth + 1, false);
}
else if (selector.StartsWith("@supports", StringComparison.OrdinalIgnoreCase) && selector.Contains("display: flex", StringComparison.OrdinalIgnoreCase)) ParseSheet(body, depth + 1, false);
else Note("Unsupported CSS at-rule: " + selector.Split(' ')[0]);
continue;
}
var parsed = ParseDeclarations(body);
foreach (string part in Split(selector, ','))
{
if (rules.Count >= limits.MaximumRules) throw new BrowserLimitException("CSS rule limit reached.");
string trimmed = part.Trim();
if (trimmed.Length is 0 or > 2048) { Note("Invalid CSS selector length"); continue; }
string pseudo="";
foreach(string name in new[]{"before","after","marker"})
{
string suffix="::"+name;
if(trimmed.EndsWith(suffix,StringComparison.OrdinalIgnoreCase)){pseudo=name;trimmed=trimmed[..^suffix.Length];break;}
if(name!="marker"&&trimmed.EndsWith(":"+name,StringComparison.OrdinalIgnoreCase)){pseudo=name;trimmed=trimmed[..^(name.Length+1)];break;}
}
if (trimmed.Contains("::", StringComparison.Ordinal) || trimmed.EndsWith(":before", StringComparison.Ordinal) || trimmed.EndsWith(":after", StringComparison.Ordinal))
{ Note("CSS generated pseudo-elements are not laid out"); continue; }
try
{
if(pseudo.Length>0&&trimmed.Length==0)trimmed="*";
var matcher = selectorParser.ParseSelector(trimmed);
if (matcher is null) { Note("Unsupported selector: " + trimmed); continue; }
var priority = matcher.Specificity;
AddRule(new(trimmed, matcher, priority.Ids * 65536 + priority.Classes * 256 + priority.Tags + (pseudo.Length>0 ? 1 : 0), parsed, ++order, pseudo));
}
catch (Exception error) when (error is not OutOfMemoryException and not StackOverflowException)
{ Note("Unsupported selector: " + trimmed); }
}
}
}
private List<Declaration> ParseDeclarations(string text)
{
var result = new List<Declaration>();
foreach (string rawDeclaration in Split(text, ';'))
{
string declaration=WithoutComments(rawDeclaration);
int colon = Find(declaration, 0, ':'); if (colon < 1) continue;
string name = declaration[..colon].Trim().ToLowerInvariant(), value = declaration[(colon + 1)..].Trim();
bool important = value.EndsWith("!important", StringComparison.OrdinalIgnoreCase);
if (important) value = value[..^10].TrimEnd();
if (value.Length == 0) continue;
if (++declarations > limits.MaximumDeclarations) throw new BrowserLimitException("CSS declaration limit reached.");
foreach (var expanded in Expand(name, value, important)) result.Add(expanded);
}
return result;
}
private IEnumerable<Declaration> Expand(string name, string value, bool important)
{
if (name == "inset")
{
string[] tokens = Words(value); if (tokens.Length is < 1 or > 4) yield break;
string[] sides = [tokens[0], tokens.Length > 1 ? tokens[1] : tokens[0], tokens.Length > 2 ? tokens[2] : tokens[0], tokens.Length > 3 ? tokens[3] : tokens.Length > 1 ? tokens[1] : tokens[0]];
string[] directions = ["top", "right", "bottom", "left"];
for (int i = 0; i < 4; i++) yield return new(directions[i], sides[i], important);
yield break;
}
if (name == "list-style")
{
foreach (string token in Words(value))
if (token is "inside" or "outside") yield return new("list-style-position", token, important);
else if (token is "none" or "disc" or "circle" or "square" or "decimal" or "lower-alpha" or "upper-alpha") yield return new("list-style-type", token, important);
yield break;
}
if (name is "margin" or "padding" or "border-width" or "border-color" or "border-style")
{
string[] tokens = Words(value); if (tokens.Length is < 1 or > 4) yield break;
string[] sides = [tokens[0], tokens.Length > 1 ? tokens[1] : tokens[0], tokens.Length > 2 ? tokens[2] : tokens[0], tokens.Length > 3 ? tokens[3] : tokens.Length > 1 ? tokens[1] : tokens[0]];
string[] directions = ["top", "right", "bottom", "left"];
for (int i = 0; i < 4; i++)
yield return new(name.StartsWith("border-", StringComparison.Ordinal) ? "border-" + directions[i] + name[6..] : name + "-" + directions[i], sides[i], important);
yield break;
}
if (name == "border" || name is "border-top" or "border-right" or "border-bottom" or "border-left")
{
string width = "medium", style = "none", color = "currentcolor";
foreach (string token in Words(value))
if (token is "none" or "hidden" or "solid" or "dashed" or "dotted" or "double") style = token;
else if (char.IsDigit(token[0]) || token is "thin" or "medium" or "thick") width = token;
else color = token;
width = width switch { "thin" => "1px", "medium" => "3px", "thick" => "5px", _ => width };
foreach (string side in name == "border" ? new[] { "top", "right", "bottom", "left" } : [name[7..]])
{
yield return new("border-" + side + "-width", width, important); yield return new("border-" + side + "-style", style, important);
yield return new("border-" + side + "-color", color, important);
}
yield break;
}
if (name == "background")
{
foreach (string token in Words(value))
if (token.StartsWith('#') || token.StartsWith("rgb") || token.StartsWith("color-mix(") || token.StartsWith("var(") || token is "transparent" or "white" or "black" or "red" or "blue" or "green")
yield return new("background-color", token, important);
if (value.Contains("gradient(")) yield return new("background-image", value, important);
if (value.Contains("url(")) Note("CSS URL background images are not painted");
yield break;
}
if (name == "flex")
{
string[] words = Words(value);
if (value == "none") words = ["0", "0", "auto"];
if (value == "auto") words = ["1", "1", "auto"];
if (words.Length > 0) yield return new("flex-grow", words[0], important);
yield return new("flex-shrink", words.Length > 1 ? words[1] : "1", important);
yield return new("flex-basis", words.Length > 2 ? words[2] : "0px", important);
yield break;
}
if (name == "gap")
{
string[] tokens = Words(value);
if (tokens.Length is 1 or 2)
{ yield return new("row-gap", tokens[0], important); yield return new("column-gap", tokens[^1], important); }
yield break;
}
if (name == "font")
{
if (value is "inherit" or "initial" or "unset")
{ foreach (string property in new[] { "font-size", "line-height", "font-style", "font-weight", "font-family" }) yield return new(property, value, important); yield break; }
string[] tokens = Words(value); int sizeIndex = -1;
for (int index = 0; index < tokens.Length; index++)
if (CssValue.Length(tokens[index].Split('/')[0], 16, 16, viewport, 16) is not null) { sizeIndex = index; break; }
if (sizeIndex < 0 || sizeIndex == tokens.Length - 1) { Note("Unsupported CSS system/invalid font shorthand"); yield break; }
string style = "normal", weight = "normal";
foreach (string token in tokens.Take(sizeIndex))
{ if (token is "italic" or "oblique") style = token; else if (token is "bold" or "bolder" || CssValue.Number(token, 0) >= 100) weight = token; }
string[] sizeParts = tokens[sizeIndex].Split('/', 2); string line = sizeParts.Length > 1 ? sizeParts[1] : "normal";
int familyIndex = sizeIndex + 1;
if (familyIndex < tokens.Length && tokens[familyIndex] == "/")
{ if (familyIndex + 1 >= tokens.Length) yield break; line = tokens[familyIndex + 1]; familyIndex += 2; }
if (familyIndex >= tokens.Length) yield break;
yield return new("font-size", sizeParts[0], important); yield return new("line-height", line, important);
yield return new("font-style", style, important); yield return new("font-weight", weight, important);
yield return new("font-family", string.Join(' ', tokens.Skip(familyIndex)), important); yield break;
}
if (name is "text-decoration" or "letter-spacing")
{ Note("CSS property is retained but not painted: " + name); }
if (!Supported.Contains(name) && !name.StartsWith("--", StringComparison.Ordinal) &&
!name.StartsWith("margin-", StringComparison.Ordinal) && !name.StartsWith("padding-", StringComparison.Ordinal) && !name.StartsWith("border-", StringComparison.Ordinal))
{ Note("Unsupported CSS property: " + name); yield break; }
yield return new(name, value, important);
}
private bool MediaMatches(string condition)
{
condition = condition.Trim().ToLowerInvariant();
if (condition.Contains("print") || condition.Contains("forced-colors") || condition.Contains("prefers-")) return false;
foreach (string feature in condition.Split('(').Skip(1))
{
string[] pair = feature.TrimEnd(')', ' ').Split(':', 2); if (pair.Length != 2) continue;
string name = pair[0].Trim();
double? length = CssValue.Length(pair[1].TrimEnd(')', ' '), name.EndsWith("height", StringComparison.Ordinal) ? viewport.Height : viewport.Width, 16, viewport, 16);
if (length is null) return false;
if (name == "min-width" && viewport.Width < length || name == "max-width" && viewport.Width > length ||
name == "min-height" && viewport.Height < length || name == "max-height" && viewport.Height > length) return false;
}
return true;
}
private void Note(string message) { if (messages.Count < 256 && messages.Add(message)) diagnostics.Add(message); }
private static Dictionary<string, string> Defaults(IElement element)
{
string tag = element.LocalName;
string display = tag is "html" or "body" or "div" or "p" or "pre" or "section" or "main" or "header" or "footer" or "nav" or "article" or "aside" or "ul" or "ol" or "h1" or "h2" or "h3" or "h4" or "h5" or "h6" or "form" or "blockquote" or "hr" ? "block" : tag == "li" ? "list-item" :
tag == "table" ? "table" : tag is "thead" or "tbody" or "tfoot" ? "table-row-group" : tag == "tr" ? "table-row" : tag is "th" or "td" ? "table-cell" :
tag is "head" or "script" or "style" or "link" or "meta" or "title" or "template" ? "none" : tag is "input" or "button" or "textarea" or "select" or "img" or "canvas" ? "inline-block" : "inline";
var values = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase) { ["display"] = display };
if (tag == "body") foreach (string side in new[] { "top", "right", "bottom", "left" }) values["margin-" + side] = "8px";
if (tag is "input" or "textarea" or "button" or "select")
{
values["background-color"] = "white"; values["color"] = "black";
foreach (string side in new[] { "top", "right", "bottom", "left" })
{
values["padding-" + side] = side is "top" or "bottom" ? "2px" : "4px";
values["border-" + side + "-width"] = "1px"; values["border-" + side + "-style"] = "solid";
values["border-" + side + "-color"] = "#808080";
}
if (tag == "input") values["width"] = "160px";
if (tag == "textarea") { values["width"] = "320px"; values["height"] = "60px"; values["white-space"] = "pre-wrap"; }
values["overflow"] = "hidden";
}
if (element is IHtmlInputElement input && input.Type == "hidden") values["display"] = "none";
if (element is IHtmlInputElement mark && mark.Type is "checkbox" or "radio")
{ values["width"] = "13px"; values["height"] = "13px"; }
if (element is IHtmlCanvasElement canvas)
{ values["width"] = canvas.Width + "px"; values["height"] = canvas.Height + "px"; }
if (tag == "pre") values["white-space"] = "pre";
if (tag is "ul" or "ol") { values["list-style-type"] = tag == "ul" ? "disc" : "decimal"; values["padding-left"] = "40px"; }
if (tag is "h1" or "h2" or "h3" or "h4" or "h5" or "h6")
values["font-size"] = tag switch { "h1" => "2em", "h2" => "1.5em", "h3" => "1.17em", "h5" => ".83em", "h6" => ".67em", _ => "1em" };
if (tag is "em" or "i") values["font-style"] = "italic";
if (tag == "a") values["color"] = "blue";
if (tag is "strong" or "b" or "h1" or "h2" or "h3") values["font-weight"] = "700";
return values;
}
private static string? Variables(string value, Dictionary<string, string> values, int depth)
{
if (depth > 8) return null;
int start = value.IndexOf("var(", StringComparison.Ordinal); if (start < 0) return value;
int end = Matching(value, start + 3, '(', ')'); if (end < 0) return null;
string inside = value[(start + 4)..end]; int comma = inside.IndexOf(',');
string name = (comma < 0 ? inside : inside[..comma]).Trim();
string? replacement = values.GetValueOrDefault(name) ?? (comma < 0 ? null : inside[(comma + 1)..].Trim());
if (replacement is null) return null;
return Variables(value[..start] + replacement + value[(end + 1)..], values, depth + 1);
}
internal static int Specificity(string selector)
{
int ids = 0, classes = 0, tags = 0; bool typeStart = true;
for (int i = 0; i < selector.Length; i++)
{
char c = selector[i];
if (c == '[') { classes++; int close = Matching(selector, i, '[', ']'); if (close >= 0) i = close; typeStart = false; }
else if (c == '#') { ids++; typeStart = false; }
else if (c == '.') { classes++; typeStart = false; }
else if (c == ':')
{
int start = i + 1; while (i + 1 < selector.Length && (char.IsLetterOrDigit(selector[i + 1]) || selector[i + 1] == '-')) i++;
string name = selector[start..(i + 1)];
if (i + 1 < selector.Length && selector[i + 1] == '(')
{
int close = Matching(selector, i + 1, '(', ')'); if (close < 0) break;
if (name is "not" or "is" or "has")
{
int nested = Split(selector[(i + 2)..close], ',').Select(Specificity).DefaultIfEmpty().Max();
ids += nested / 65536; classes += nested / 256 % 256; tags += nested % 256;
}
else if (name != "where") classes++;
i = close;
}
else classes++;
typeStart = false;
}
else if (char.IsWhiteSpace(c) || c is '>' or '+' or '~' or ',') typeStart = true;
else if (typeStart && char.IsLetter(c)) { tags++; typeStart = false; }
else if (c == '*') typeStart = false;
}
return Math.Min(ids, 255) * 65536 + Math.Min(classes, 255) * 256 + Math.Min(tags, 255);
}
private static string WithoutComments(string text,int start=0,int length=-1)
{
if(length<0)length=text.Length-start;
int limit=start+length;
if(text.IndexOf("/*",start,length,StringComparison.Ordinal)<0)return start==0&&length==text.Length?text:text.Substring(start,length);
var result = new System.Text.StringBuilder(Math.Min(length,1024)); char quote = '\0';
for (int i = start; i < limit; i++)
{
if (quote != '\0') { result.Append(text[i]); if (text[i] == quote && (i == 0 || text[i - 1] != '\\')) quote = '\0'; }
else if (text[i] is '\'' or '"') { quote = text[i]; result.Append(text[i]); }
else if (text[i] == '/' && i + 1 < limit && text[i + 1] == '*') { int end = text.IndexOf("*/", i + 2, StringComparison.Ordinal); i = end < 0||end>=limit ? limit : end + 1; }
else result.Append(text[i]);
}
return result.ToString();
}
private static int Find(string text, int offset, char target)
{
char quote = '\0'; int parentheses = 0, brackets = 0;
for (int i = offset; i < text.Length; i++)
{
char c = text[i]; if (quote != '\0') { if (c == quote && (i == 0 || text[i - 1] != '\\')) quote = '\0'; continue; }
if (c is '\'' or '"') { quote = c; continue; }
if(c=='/'&&i+1<text.Length&&text[i+1]=='*'){int end=text.IndexOf("*/",i+2,StringComparison.Ordinal);if(end<0)return -1;i=end+1;continue;}
if (c == target && parentheses == 0 && brackets == 0) return i;
if (c == '(') parentheses++; else if (c == ')') parentheses--; else if (c == '[') brackets++; else if (c == ']') brackets--;
}
return -1;
}
private static int Matching(string text, int open, char left, char right)
{
int depth = 0; char quote = '\0';
for (int i = open; i < text.Length; i++)
{
char c = text[i]; if (quote != '\0') { if (c == quote && text[i - 1] != '\\') quote = '\0'; continue; }
if (c is '\'' or '"') { quote = c; continue; }
if(c=='/'&&i+1<text.Length&&text[i+1]=='*'){int end=text.IndexOf("*/",i+2,StringComparison.Ordinal);if(end<0)return -1;i=end+1;continue;}
if (c == left) depth++; else if (c == right && --depth == 0) return i;
}
return -1;
}
private static IEnumerable<string> Split(string text, char delimiter)
{
int start = 0;
while (start < text.Length)
{
int end = Find(text, start, delimiter); if (end < 0) { yield return WithoutComments(text,start,text.Length-start); break; }
yield return WithoutComments(text,start,end-start); start = end + 1;
}
}
private static string[] Words(string value) => Split(value.Replace('\t', ' ').Replace('\n', ' '), ' ').Where(word => !string.IsNullOrWhiteSpace(word)).Select(word => word.Trim()).ToArray();
}
using AngleSharp.Dom;
using AngleSharp.Html.Dom;
using AngleSharp.Css.Dom;
using AngleSharp.Css.Parser;
using AngleSharp.Css;
using AngleSharp;
namespace XFESystem.Browser;
/// <summary>Bounded author cascade. Original implementation of the supported CSS subset; no dynamic compiler.</summary>
internal sealed class BrowserCssCascade
{
private readonly BrowserCssLimits limits;
private BrowserViewport viewport, parsedViewport;
private List<string> diagnostics;
private bool blockAuthorStyles, validSources;
private readonly InteractionHolder interaction = new();
private ICssSelectorParser selectorParser;
public BrowserCssCascade(BrowserCssLimits limits, BrowserViewport viewport, List<string> diagnostics, bool blockAuthorStyles = false,
BrowserInteractionState? interaction = null)
{
this.limits = limits; this.viewport = viewport; this.diagnostics = diagnostics; this.blockAuthorStyles = blockAuthorStyles;
this.interaction.Value = interaction ?? new(); selectorParser = CreateParser(this.interaction);
}
private sealed record Rule(string Selector, ISelector Matcher, int Specificity, List<Declaration> Declarations, int Order, string Pseudo = "");
private readonly record struct Declaration(string Name, string Value, bool Important);
private readonly record struct Winner(bool Important, bool Inline, int Specificity, long Order, string Value);
private readonly List<Rule> rules = [];
private readonly List<Rule> generalRules = [];
private readonly Dictionary<string, List<Rule>> classRules = new(StringComparer.OrdinalIgnoreCase);
private IEnumerable<Rule> Candidates(IElement element)
{
foreach (var rule in generalRules) yield return rule;
foreach (string name in element.ClassList)
if (classRules.TryGetValue(name, out var matching)) foreach (var rule in matching) yield return rule;
}
private void AddRule(Rule rule)
{
rules.Add(rule);
string selector = rule.Selector;
// Only a single unescaped ASCII class can enter the index. All complex
// selectors keep the full matcher, including :is/:not and combinators.
if (selector.Length > 1 && selector[0] == '.' &&
selector.AsSpan(1).IndexOfAnyExcept("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-".AsSpan()) < 0)
{
string name = selector[1..];
if (!classRules.TryGetValue(name, out var matching)) classRules[name] = matching = [];
matching.Add(rule);
}
else generalRules.Add(rule);
}
private int characters, declarations, comparisons, order;
private int parsedDeclarations, parsedOrder;
private readonly List<string> sources = [], currentSources = [], parseMessages = [], textSegments = [];
private readonly HashSet<IElement> styleOwners = new(ReferenceEqualityComparer.Instance);
private readonly Dictionary<IElement, ComplexTextSnapshot> complexTexts = new(ReferenceEqualityComparer.Instance);
private readonly List<IElement> removedOwners = [];
private sealed record ComplexTextSnapshot(string[] Segments, string Text);
private sealed class InteractionHolder { public BrowserInteractionState Value = new(); }
public long ParseGeneration { get; private set; }
public long ReuseCount { get; private set; }
internal bool InteractionChangesStyles(BrowserInteractionState before, BrowserInteractionState after, IEnumerable<IElement> elements)
{
var relevant = rules.Where(rule => rule.Selector.Contains(":hover", StringComparison.OrdinalIgnoreCase) ||
rule.Selector.Contains(":active", StringComparison.OrdinalIgnoreCase) || rule.Selector.Contains(":focus", StringComparison.OrdinalIgnoreCase)).ToArray();
if (relevant.Length == 0) return false;
var saved = interaction.Value;
bool changed = false; int work = 0;
try
{
foreach (var element in elements)
{
foreach (var rule in relevant)
{
if (++work > limits.MaximumSelectorEvaluations) { changed = true; break; }
interaction.Value = before; bool first = rule.Matcher.Match(element);
interaction.Value = after; bool second = rule.Matcher.Match(element);
if (first != second) { changed = true; break; }
}
if (changed) break;
}
}
catch { interaction.Value = saved; return true; }
interaction.Value = saved;
return changed;
}
private readonly HashSet<string> messages = new(StringComparer.Ordinal);
private static ICssSelectorParser CreateParser(InteractionHolder state)
{
var factory = new DefaultPseudoClassSelectorFactory();
foreach (string name in new[] { "hover", "active", "focus", "focus-within", "focus-visible" })
{ factory.Unregister(name); factory.Register(name, new StateSelector(name, state)); }
var services = Configuration.Default.Services.Where(service => service is not IPseudoClassSelectorFactory).Append(factory);
return BrowsingContext.New(new Configuration(services)).GetService<ICssSelectorParser>()!;
}
private sealed class StateSelector(string name, InteractionHolder state) : ISelector
{
public string Text => ":" + name;
public Priority Specificity => Priority.OneClass;
public bool Match(IElement element, IElement? scope)
{
var current = state.Value;
INode? target = name switch { "hover" => current.Hovered, "active" => current.Active, _ => current.Focus };
if (name is "focus" or "focus-visible") return ReferenceEquals(element, target);
while (target is not null) { if (ReferenceEquals(element, target)) return true; target = target.Parent; }
return false;
}
public void Accept(ISelectorVisitor visitor) => visitor.PseudoClass(name);
}
private static readonly HashSet<string> Inherited = new(StringComparer.OrdinalIgnoreCase)
{ "color", "font-size", "font-family", "font-weight", "font-style", "line-height", "white-space", "visibility", "text-align", "cursor", "letter-spacing", "pointer-events", "list-style-type", "list-style-position", "border-collapse", "border-spacing", "overflow-wrap", "word-break" };
private static readonly HashSet<string> Supported = new(StringComparer.OrdinalIgnoreCase)
{
"display", "position", "visibility", "overflow", "overflow-x", "overflow-y", "pointer-events", "opacity", "z-index", "transform",
"width", "height", "min-width", "max-width", "min-height", "max-height", "left", "right", "top", "bottom", "box-sizing",
"margin", "padding", "border", "border-width", "border-color", "border-style", "background", "background-color", "color",
"font", "font-family", "font-size", "font-weight", "font-style", "line-height", "white-space", "text-align", "text-decoration", "cursor", "letter-spacing",
"list-style-type", "list-style-position", "list-style", "border-collapse", "border-spacing", "overflow-wrap", "word-break", "box-shadow", "background-image", "vertical-align", "text-transform", "content",
"flex", "flex-grow", "flex-shrink", "flex-basis", "flex-direction", "flex-wrap", "justify-content", "align-items", "align-self", "gap", "row-gap", "column-gap", "order"
};
public void Load(IDocument document, IReadOnlyList<BrowserStyleSheet>? linked)
{
currentSources.Clear(); styleOwners.Clear(); int sourceCharacters = 0;
void Source(string text)
{
if (text.Length > limits.MaximumCssCharacters - sourceCharacters) throw new BrowserLimitException("CSS character limit reached.");
if (currentSources.Count >= limits.MaximumNodes) throw new BrowserLimitException("CSS source count limit reached.");
sourceCharacters += text.Length; currentSources.Add(text);
}
if (!blockAuthorStyles)
{
foreach (var element in document.QuerySelectorAll("style,link"))
{
if (element.LocalName == "style")
{ styleOwners.Add(element); Source(StyleText(element, limits.MaximumCssCharacters - sourceCharacters)); }
else if (linked is not null)
foreach (var sheet in linked)
if (ReferenceEquals(sheet.Owner, element)) Source(sheet.CssText);
}
if (linked is not null) foreach (var sheet in linked) if (sheet.Owner is null) Source(sheet.CssText);
}
removedOwners.Clear();
foreach (var owner in complexTexts.Keys) if (!styleOwners.Contains(owner)) removedOwners.Add(owner);
foreach (var owner in removedOwners) complexTexts.Remove(owner);
bool reuse = validSources && parsedViewport.Width == viewport.Width && parsedViewport.Height == viewport.Height &&
sources.Count == currentSources.Count;
if (reuse)
for (int index = 0; index < sources.Count; index++)
if (!string.Equals(sources[index], currentSources[index], StringComparison.Ordinal)) { reuse = false; break; }
if (reuse)
{
// Counters are per layout, including inline declarations. Reusing
// compiled rules never avoids any configured source/output budget.
characters = sourceCharacters; declarations = parsedDeclarations; order = parsedOrder;
if (characters > limits.MaximumCssCharacters || declarations > limits.MaximumDeclarations || rules.Count > limits.MaximumRules)
throw new BrowserLimitException("Cached CSS exceeds the configured layout budget.");
foreach (string message in parseMessages) Note(message);
ReuseCount++;
}
else
{
validSources = false; rules.Clear(); generalRules.Clear(); classRules.Clear(); sources.Clear(); parseMessages.Clear(); characters = declarations = order = 0;
foreach (string source in currentSources) ParseSheet(source);
parsedDeclarations = declarations; parsedOrder = order; parsedViewport = viewport;
sources.AddRange(currentSources); parseMessages.AddRange(messages); validSources = true; ParseGeneration++;
}
if (blockAuthorStyles) Note("Author styles disabled by the browser's conservative CSP profile");
}
public void BeginLayout(BrowserViewport viewport, List<string> diagnostics, bool blockAuthorStyles, BrowserInteractionState state)
{
this.viewport = viewport; this.diagnostics = diagnostics; this.blockAuthorStyles = blockAuthorStyles;
interaction.Value = state; comparisons = 0; messages.Clear();
}
public void ResetDocument()
{
validSources = false; rules.Clear(); generalRules.Clear(); classRules.Clear(); sources.Clear(); currentSources.Clear(); parseMessages.Clear(); complexTexts.Clear();
styleOwners.Clear(); removedOwners.Clear(); textSegments.Clear(); messages.Clear(); interaction.Value = new();
selectorParser = CreateParser(interaction); characters = declarations = comparisons = order = parsedDeclarations = parsedOrder = 0;
}
private string StyleText(IElement owner, int maximum)
{
var children = owner.ChildNodes;
if (children.Length == 0) { complexTexts.Remove(owner); return ""; }
if (children.Length == 1 && children[0] is IText text)
{ complexTexts.Remove(owner); return text.Data; }
return ComplexStyleText(owner, maximum);
}
private string ComplexStyleText(IElement owner, int maximum)
{
textSegments.Clear(); int characters = 0, nodes = 0;
void Read(INode node, int depth)
{
if (++nodes > limits.MaximumNodes || depth > limits.MaximumDepth) throw new BrowserLimitException("CSS style text DOM bound reached.");
if (node is IText text)
{
string data = text.Data;
if (data.Length > maximum - characters) throw new BrowserLimitException("CSS character limit reached.");
characters += data.Length; textSegments.Add(data);
}
else foreach (var child in node.ChildNodes) Read(child, depth + 1);
}
foreach (var child in owner.ChildNodes) Read(child, 1);
if (complexTexts.TryGetValue(owner, out var snapshot) && snapshot.Segments.Length == textSegments.Count)
{
bool unchanged = true;
for (int index = 0; index < textSegments.Count; index++)
if (!string.Equals(snapshot.Segments[index], textSegments[index], StringComparison.Ordinal)) { unchanged = false; break; }
if (unchanged) return snapshot.Text;
}
string result = string.Concat(textSegments);
complexTexts[owner] = new(textSegments.ToArray(), result);
return result;
}
public CssComputedStyle Compute(IElement element, CssComputedStyle? parent)
{
var values = Defaults(element);
bool initialColor = values.ContainsKey("color");
bool initialFontSize = values.ContainsKey("font-size");
if (parent is not null)
{
foreach (var pair in parent.Properties)
if ((Inherited.Contains(pair.Key) || pair.Key.StartsWith("--", StringComparison.Ordinal)) && !values.ContainsKey(pair.Key)) values[pair.Key] = pair.Value;
if (!initialFontSize) values["font-size"] = CssValue.Pixels(parent.Font.Size);
if (values.TryGetValue("line-height", out string? inheritedLine) && inheritedLine != "normal" &&
!double.IsFinite(CssValue.Number(inheritedLine, double.NaN))) values["line-height"] = CssValue.Pixels(parent.LineHeight);
if (!initialColor) values["color"] = CssValue.SerializeColor(parent.Color);
}
var winning = new Dictionary<string, Winner>(StringComparer.OrdinalIgnoreCase);
foreach (var rule in Candidates(element))
{
if (rule.Pseudo.Length!=0) continue;
if (++comparisons > limits.MaximumSelectorEvaluations) throw new BrowserLimitException("CSS selector evaluation limit reached.");
bool matches;
try { matches = rule.Matcher.Match(element, element.Owner?.DocumentElement ?? element); }
catch (Exception error) when (error is not OutOfMemoryException and not StackOverflowException)
{ Note("Unsupported selector: " + rule.Selector); continue; }
if (matches) Apply(rule.Declarations, false, rule.Specificity, rule.Order, winning);
}
string? inline = element.GetAttribute("style");
if (!blockAuthorStyles && !string.IsNullOrWhiteSpace(inline)) Apply(ParseDeclarations(inline), true, 0, ++order, winning);
foreach (var pair in winning)
{
string value = pair.Value.Value;
if (value is "inherit" or "unset" && (value == "inherit" || Inherited.Contains(pair.Key)))
value = parent is null ? "initial" : pair.Key == "font-size" ? CssValue.Pixels(parent.Font.Size) :
pair.Key == "line-height" && !double.IsFinite(CssValue.Number(parent.Get("line-height", "normal"), double.NaN)) && parent.Get("line-height", "normal") != "normal" ? CssValue.Pixels(parent.LineHeight) : parent.GetPropertyValue(pair.Key);
if (value == "initial" || value == "unset") values.Remove(pair.Key);
else values[pair.Key] = value;
}
foreach (string key in values.Keys.ToArray())
{
string? resolved = Variables(values[key], values, 0);
if (resolved is null) values.Remove(key); else values[key] = resolved;
}
return new(values, parent, viewport);
}
public CssComputedStyle ComputeMarker(IElement element, CssComputedStyle style)=>ComputePseudo(element,"marker",style)??style;
public CssComputedStyle? ComputePseudo(IElement element,string pseudo,CssComputedStyle style)
{
// Most elements have no generated content. Test the bounded, indexed
// candidates before copying the inherited custom-property environment;
// the website's theme can otherwise be copied twice for every element.
Dictionary<string, Winner>? winning = null;
foreach (var rule in Candidates(element))
{
if (rule.Pseudo!=pseudo) continue;
if (++comparisons > limits.MaximumSelectorEvaluations) throw new BrowserLimitException("CSS selector evaluation limit reached.");
if (rule.Matcher.Match(element, element.Owner?.DocumentElement ?? element))
{ winning ??= new(StringComparer.OrdinalIgnoreCase); Apply(rule.Declarations, false, rule.Specificity, rule.Order, winning); }
}
if (winning is null) return pseudo=="marker"?style:null;
// A universal reset can match ::before/::after without generating one.
// Content is not inherited into this values map, so without a winning
// content declaration the result below would always be "normal"/null.
if (pseudo!="marker" && !winning.ContainsKey("content")) return null;
var values = pseudo=="marker"?new Dictionary<string, string>(style.Properties, StringComparer.OrdinalIgnoreCase):
style.Properties.Where(pair=>Inherited.Contains(pair.Key)||pair.Key.StartsWith("--",StringComparison.Ordinal)).ToDictionary(pair=>pair.Key,pair=>pair.Value,StringComparer.OrdinalIgnoreCase);
if(pseudo!="marker")values["display"]="inline";
values["font-size"] = CssValue.Pixels(style.Font.Size);
if(values.TryGetValue("line-height",out string? line)&&line!="normal"&&!double.IsFinite(CssValue.Number(line,double.NaN)))values["line-height"]=CssValue.Pixels(style.LineHeight);
foreach (var pair in winning)
{
string value=pair.Value.Value;
if(value is "inherit" or "unset" &&(value=="inherit"||Inherited.Contains(pair.Key)))value=pair.Key=="font-size"?CssValue.Pixels(style.Font.Size):style.GetPropertyValue(pair.Key);
if(value is "initial" or "unset")values.Remove(pair.Key);else values[pair.Key]=value;
}
foreach (string key in values.Keys.ToArray())
{ string? resolved = Variables(values[key], values, 0); if (resolved is null) values.Remove(key); else values[key] = resolved; }
if(pseudo!="marker"&&values.GetValueOrDefault("content","normal") is "normal" or "none")return null;
return new(values, style, viewport);
}
private void Apply(List<Declaration> source, bool inline, int specificity, int ruleOrder, Dictionary<string, Winner> winners)
{
int index = 0;
foreach (var item in source)
{
var candidate = new Winner(item.Important, inline, specificity, ((long)ruleOrder << 32) + index++, item.Value);
if (!winners.TryGetValue(item.Name, out var old) || Greater(candidate, old)) winners[item.Name] = candidate;
}
}
private static bool Greater(Winner a, Winner b) => a.Important != b.Important ? a.Important : a.Inline != b.Inline ? a.Inline :
a.Specificity != b.Specificity ? a.Specificity > b.Specificity : a.Order >= b.Order;
private void ParseSheet(string css, int depth = 0, bool countCharacters = true)
{
if (depth > 32) throw new BrowserLimitException("CSS nested rule depth limit reached.");
if (countCharacters) characters += css.Length;
if (characters > limits.MaximumCssCharacters) throw new BrowserLimitException("CSS character limit reached.");
for (int offset = 0; offset < css.Length;)
{
while (offset < css.Length && char.IsWhiteSpace(css[offset])) offset++;
int open = Find(css, offset, '{');
int semicolon = Find(css, offset, ';');
if (open < 0) break;
if (semicolon >= 0 && semicolon < open) { Note("Unsupported CSS at-rule: " + WithoutComments(css,offset,semicolon-offset)); offset = semicolon + 1; continue; }
int close = Matching(css, open, '{', '}');
if (close < 0) { Note("Malformed CSS block ignored"); break; }
string selector = WithoutComments(css,offset,open-offset).Trim(); string body = css[(open + 1)..close]; offset = close + 1;
if (selector.StartsWith('@'))
{
if (selector.StartsWith("@media", StringComparison.OrdinalIgnoreCase))
{
if (MediaMatches(selector[6..])) ParseSheet(body, depth + 1, false);
}
else if (selector.StartsWith("@supports", StringComparison.OrdinalIgnoreCase) && selector.Contains("display: flex", StringComparison.OrdinalIgnoreCase)) ParseSheet(body, depth + 1, false);
else Note("Unsupported CSS at-rule: " + selector.Split(' ')[0]);
continue;
}
var parsed = ParseDeclarations(body);
foreach (string part in Split(selector, ','))
{
if (rules.Count >= limits.MaximumRules) throw new BrowserLimitException("CSS rule limit reached.");
string trimmed = part.Trim();
if (trimmed.Length is 0 or > 2048) { Note("Invalid CSS selector length"); continue; }
string pseudo="";
foreach(string name in new[]{"before","after","marker"})
{
string suffix="::"+name;
if(trimmed.EndsWith(suffix,StringComparison.OrdinalIgnoreCase)){pseudo=name;trimmed=trimmed[..^suffix.Length];break;}
if(name!="marker"&&trimmed.EndsWith(":"+name,StringComparison.OrdinalIgnoreCase)){pseudo=name;trimmed=trimmed[..^(name.Length+1)];break;}
}
if (trimmed.Contains("::", StringComparison.Ordinal) || trimmed.EndsWith(":before", StringComparison.Ordinal) || trimmed.EndsWith(":after", StringComparison.Ordinal))
{ Note("CSS generated pseudo-elements are not laid out"); continue; }
try
{
if(pseudo.Length>0&&trimmed.Length==0)trimmed="*";
var matcher = selectorParser.ParseSelector(trimmed);
if (matcher is null) { Note("Unsupported selector: " + trimmed); continue; }
var priority = matcher.Specificity;
AddRule(new(trimmed, matcher, priority.Ids * 65536 + priority.Classes * 256 + priority.Tags + (pseudo.Length>0 ? 1 : 0), parsed, ++order, pseudo));
}
catch (Exception error) when (error is not OutOfMemoryException and not StackOverflowException)
{ Note("Unsupported selector: " + trimmed); }
}
}
}
private List<Declaration> ParseDeclarations(string text)
{
var result = new List<Declaration>();
foreach (string rawDeclaration in Split(text, ';'))
{
string declaration=WithoutComments(rawDeclaration);
int colon = Find(declaration, 0, ':'); if (colon < 1) continue;
string name = declaration[..colon].Trim().ToLowerInvariant(), value = declaration[(colon + 1)..].Trim();
bool important = value.EndsWith("!important", StringComparison.OrdinalIgnoreCase);
if (important) value = value[..^10].TrimEnd();
if (value.Length == 0) continue;
if (++declarations > limits.MaximumDeclarations) throw new BrowserLimitException("CSS declaration limit reached.");
foreach (var expanded in Expand(name, value, important)) result.Add(expanded);
}
return result;
}
private IEnumerable<Declaration> Expand(string name, string value, bool important)
{
if (name == "inset")
{
string[] tokens = Words(value); if (tokens.Length is < 1 or > 4) yield break;
string[] sides = [tokens[0], tokens.Length > 1 ? tokens[1] : tokens[0], tokens.Length > 2 ? tokens[2] : tokens[0], tokens.Length > 3 ? tokens[3] : tokens.Length > 1 ? tokens[1] : tokens[0]];
string[] directions = ["top", "right", "bottom", "left"];
for (int i = 0; i < 4; i++) yield return new(directions[i], sides[i], important);
yield break;
}
if (name == "list-style")
{
foreach (string token in Words(value))
if (token is "inside" or "outside") yield return new("list-style-position", token, important);
else if (token is "none" or "disc" or "circle" or "square" or "decimal" or "lower-alpha" or "upper-alpha") yield return new("list-style-type", token, important);
yield break;
}
if (name is "margin" or "padding" or "border-width" or "border-color" or "border-style")
{
string[] tokens = Words(value); if (tokens.Length is < 1 or > 4) yield break;
string[] sides = [tokens[0], tokens.Length > 1 ? tokens[1] : tokens[0], tokens.Length > 2 ? tokens[2] : tokens[0], tokens.Length > 3 ? tokens[3] : tokens.Length > 1 ? tokens[1] : tokens[0]];
string[] directions = ["top", "right", "bottom", "left"];
for (int i = 0; i < 4; i++)
yield return new(name.StartsWith("border-", StringComparison.Ordinal) ? "border-" + directions[i] + name[6..] : name + "-" + directions[i], sides[i], important);
yield break;
}
if (name == "border" || name is "border-top" or "border-right" or "border-bottom" or "border-left")
{
string width = "medium", style = "none", color = "currentcolor";
foreach (string token in Words(value))
if (token is "none" or "hidden" or "solid" or "dashed" or "dotted" or "double") style = token;
else if (char.IsDigit(token[0]) || token is "thin" or "medium" or "thick") width = token;
else color = token;
width = width switch { "thin" => "1px", "medium" => "3px", "thick" => "5px", _ => width };
foreach (string side in name == "border" ? new[] { "top", "right", "bottom", "left" } : [name[7..]])
{
yield return new("border-" + side + "-width", width, important); yield return new("border-" + side + "-style", style, important);
yield return new("border-" + side + "-color", color, important);
}
yield break;
}
if (name == "background")
{
foreach (string token in Words(value))
if (token.StartsWith('#') || token.StartsWith("rgb") || token.StartsWith("color-mix(") || token.StartsWith("var(") || token is "transparent" or "white" or "black" or "red" or "blue" or "green")
yield return new("background-color", token, important);
if (value.Contains("gradient(")) yield return new("background-image", value, important);
if (value.Contains("url(")) Note("CSS URL background images are not painted");
yield break;
}
if (name == "flex")
{
string[] words = Words(value);
if (value == "none") words = ["0", "0", "auto"];
if (value == "auto") words = ["1", "1", "auto"];
if (words.Length > 0) yield return new("flex-grow", words[0], important);
yield return new("flex-shrink", words.Length > 1 ? words[1] : "1", important);
yield return new("flex-basis", words.Length > 2 ? words[2] : "0px", important);
yield break;
}
if (name == "gap")
{
string[] tokens = Words(value);
if (tokens.Length is 1 or 2)
{ yield return new("row-gap", tokens[0], important); yield return new("column-gap", tokens[^1], important); }
yield break;
}
if (name == "font")
{
if (value is "inherit" or "initial" or "unset")
{ foreach (string property in new[] { "font-size", "line-height", "font-style", "font-weight", "font-family" }) yield return new(property, value, important); yield break; }
string[] tokens = Words(value); int sizeIndex = -1;
for (int index = 0; index < tokens.Length; index++)
if (CssValue.Length(tokens[index].Split('/')[0], 16, 16, viewport, 16) is not null) { sizeIndex = index; break; }
if (sizeIndex < 0 || sizeIndex == tokens.Length - 1) { Note("Unsupported CSS system/invalid font shorthand"); yield break; }
string style = "normal", weight = "normal";
foreach (string token in tokens.Take(sizeIndex))
{ if (token is "italic" or "oblique") style = token; else if (token is "bold" or "bolder" || CssValue.Number(token, 0) >= 100) weight = token; }
string[] sizeParts = tokens[sizeIndex].Split('/', 2); string line = sizeParts.Length > 1 ? sizeParts[1] : "normal";
int familyIndex = sizeIndex + 1;
if (familyIndex < tokens.Length && tokens[familyIndex] == "/")
{ if (familyIndex + 1 >= tokens.Length) yield break; line = tokens[familyIndex + 1]; familyIndex += 2; }
if (familyIndex >= tokens.Length) yield break;
yield return new("font-size", sizeParts[0], important); yield return new("line-height", line, important);
yield return new("font-style", style, important); yield return new("font-weight", weight, important);
yield return new("font-family", string.Join(' ', tokens.Skip(familyIndex)), important); yield break;
}
if (name is "text-decoration" or "letter-spacing")
{ Note("CSS property is retained but not painted: " + name); }
if (!Supported.Contains(name) && !name.StartsWith("--", StringComparison.Ordinal) &&
!name.StartsWith("margin-", StringComparison.Ordinal) && !name.StartsWith("padding-", StringComparison.Ordinal) && !name.StartsWith("border-", StringComparison.Ordinal))
{ Note("Unsupported CSS property: " + name); yield break; }
yield return new(name, value, important);
}
private bool MediaMatches(string condition)
{
condition = condition.Trim().ToLowerInvariant();
if (condition.Contains("print") || condition.Contains("forced-colors") || condition.Contains("prefers-")) return false;
foreach (string feature in condition.Split('(').Skip(1))
{
string[] pair = feature.TrimEnd(')', ' ').Split(':', 2); if (pair.Length != 2) continue;
string name = pair[0].Trim();
double? length = CssValue.Length(pair[1].TrimEnd(')', ' '), name.EndsWith("height", StringComparison.Ordinal) ? viewport.Height : viewport.Width, 16, viewport, 16);
if (length is null) return false;
if (name == "min-width" && viewport.Width < length || name == "max-width" && viewport.Width > length ||
name == "min-height" && viewport.Height < length || name == "max-height" && viewport.Height > length) return false;
}
return true;
}
private void Note(string message) { if (messages.Count < 256 && messages.Add(message)) diagnostics.Add(message); }
private static Dictionary<string, string> Defaults(IElement element)
{
string tag = element.LocalName;
string display = tag is "html" or "body" or "div" or "p" or "pre" or "section" or "main" or "header" or "footer" or "nav" or "article" or "aside" or "ul" or "ol" or "h1" or "h2" or "h3" or "h4" or "h5" or "h6" or "form" or "blockquote" or "hr" ? "block" : tag == "li" ? "list-item" :
tag == "table" ? "table" : tag is "thead" or "tbody" or "tfoot" ? "table-row-group" : tag == "tr" ? "table-row" : tag is "th" or "td" ? "table-cell" :
tag is "head" or "script" or "style" or "link" or "meta" or "title" or "template" ? "none" : tag is "input" or "button" or "textarea" or "select" or "img" or "canvas" ? "inline-block" : "inline";
var values = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase) { ["display"] = display };
if (tag == "body") foreach (string side in new[] { "top", "right", "bottom", "left" }) values["margin-" + side] = "8px";
if (tag is "input" or "textarea" or "button" or "select")
{
values["background-color"] = "white"; values["color"] = "black";
foreach (string side in new[] { "top", "right", "bottom", "left" })
{
values["padding-" + side] = side is "top" or "bottom" ? "2px" : "4px";
values["border-" + side + "-width"] = "1px"; values["border-" + side + "-style"] = "solid";
values["border-" + side + "-color"] = "#808080";
}
if (tag == "input") values["width"] = "160px";
if (tag == "textarea") { values["width"] = "320px"; values["height"] = "60px"; values["white-space"] = "pre-wrap"; }
values["overflow"] = "hidden";
}
if (element is IHtmlInputElement input && input.Type == "hidden") values["display"] = "none";
if (element is IHtmlInputElement mark && mark.Type is "checkbox" or "radio")
{ values["width"] = "13px"; values["height"] = "13px"; }
if (element is IHtmlCanvasElement canvas)
{ values["width"] = canvas.Width + "px"; values["height"] = canvas.Height + "px"; }
if (tag == "pre") values["white-space"] = "pre";
if (tag is "ul" or "ol") { values["list-style-type"] = tag == "ul" ? "disc" : "decimal"; values["padding-left"] = "40px"; }
if (tag is "h1" or "h2" or "h3" or "h4" or "h5" or "h6")
values["font-size"] = tag switch { "h1" => "2em", "h2" => "1.5em", "h3" => "1.17em", "h5" => ".83em", "h6" => ".67em", _ => "1em" };
if (tag is "em" or "i") values["font-style"] = "italic";
if (tag == "a") values["color"] = "blue";
if (tag is "strong" or "b" or "h1" or "h2" or "h3") values["font-weight"] = "700";
return values;
}
private static string? Variables(string value, Dictionary<string, string> values, int depth)
{
if (depth > 8) return null;
int start = value.IndexOf("var(", StringComparison.Ordinal); if (start < 0) return value;
int end = Matching(value, start + 3, '(', ')'); if (end < 0) return null;
string inside = value[(start + 4)..end]; int comma = inside.IndexOf(',');
string name = (comma < 0 ? inside : inside[..comma]).Trim();
string? replacement = values.GetValueOrDefault(name) ?? (comma < 0 ? null : inside[(comma + 1)..].Trim());
if (replacement is null) return null;
return Variables(value[..start] + replacement + value[(end + 1)..], values, depth + 1);
}
internal static int Specificity(string selector)
{
int ids = 0, classes = 0, tags = 0; bool typeStart = true;
for (int i = 0; i < selector.Length; i++)
{
char c = selector[i];
if (c == '[') { classes++; int close = Matching(selector, i, '[', ']'); if (close >= 0) i = close; typeStart = false; }
else if (c == '#') { ids++; typeStart = false; }
else if (c == '.') { classes++; typeStart = false; }
else if (c == ':')
{
int start = i + 1; while (i + 1 < selector.Length && (char.IsLetterOrDigit(selector[i + 1]) || selector[i + 1] == '-')) i++;
string name = selector[start..(i + 1)];
if (i + 1 < selector.Length && selector[i + 1] == '(')
{
int close = Matching(selector, i + 1, '(', ')'); if (close < 0) break;
if (name is "not" or "is" or "has")
{
int nested = Split(selector[(i + 2)..close], ',').Select(Specificity).DefaultIfEmpty().Max();
ids += nested / 65536; classes += nested / 256 % 256; tags += nested % 256;
}
else if (name != "where") classes++;
i = close;
}
else classes++;
typeStart = false;
}
else if (char.IsWhiteSpace(c) || c is '>' or '+' or '~' or ',') typeStart = true;
else if (typeStart && char.IsLetter(c)) { tags++; typeStart = false; }
else if (c == '*') typeStart = false;
}
return Math.Min(ids, 255) * 65536 + Math.Min(classes, 255) * 256 + Math.Min(tags, 255);
}
private static string WithoutComments(string text,int start=0,int length=-1)
{
if(length<0)length=text.Length-start;
int limit=start+length;
if(text.IndexOf("/*",start,length,StringComparison.Ordinal)<0)return start==0&&length==text.Length?text:text.Substring(start,length);
var result = new System.Text.StringBuilder(Math.Min(length,1024)); char quote = '\0';
for (int i = start; i < limit; i++)
{
if (quote != '\0') { result.Append(text[i]); if (text[i] == quote && (i == 0 || text[i - 1] != '\\')) quote = '\0'; }
else if (text[i] is '\'' or '"') { quote = text[i]; result.Append(text[i]); }
else if (text[i] == '/' && i + 1 < limit && text[i + 1] == '*') { int end = text.IndexOf("*/", i + 2, StringComparison.Ordinal); i = end < 0||end>=limit ? limit : end + 1; }
else result.Append(text[i]);
}
return result.ToString();
}
private static int Find(string text, int offset, char target)
{
char quote = '\0'; int parentheses = 0, brackets = 0;
for (int i = offset; i < text.Length; i++)
{
char c = text[i]; if (quote != '\0') { if (c == quote && (i == 0 || text[i - 1] != '\\')) quote = '\0'; continue; }
if (c is '\'' or '"') { quote = c; continue; }
if(c=='/'&&i+1<text.Length&&text[i+1]=='*'){int end=text.IndexOf("*/",i+2,StringComparison.Ordinal);if(end<0)return -1;i=end+1;continue;}
if (c == target && parentheses == 0 && brackets == 0) return i;
if (c == '(') parentheses++; else if (c == ')') parentheses--; else if (c == '[') brackets++; else if (c == ']') brackets--;
}
return -1;
}
private static int Matching(string text, int open, char left, char right)
{
int depth = 0; char quote = '\0';
for (int i = open; i < text.Length; i++)
{
char c = text[i]; if (quote != '\0') { if (c == quote && text[i - 1] != '\\') quote = '\0'; continue; }
if (c is '\'' or '"') { quote = c; continue; }
if(c=='/'&&i+1<text.Length&&text[i+1]=='*'){int end=text.IndexOf("*/",i+2,StringComparison.Ordinal);if(end<0)return -1;i=end+1;continue;}
if (c == left) depth++; else if (c == right && --depth == 0) return i;
}
return -1;
}
private static IEnumerable<string> Split(string text, char delimiter)
{
int start = 0;
while (start < text.Length)
{
int end = Find(text, start, delimiter); if (end < 0) { yield return WithoutComments(text,start,text.Length-start); break; }
yield return WithoutComments(text,start,end-start); start = end + 1;
}
}
private static string[] Words(string value) => Split(value.Replace('\t', ' ').Replace('\n', ' '), ' ').Where(word => !string.IsNullOrWhiteSpace(word)).Select(word => word.Trim()).ToArray();
}