Table of Contents

CSS

Egami.Css parses and applies CSS in managed code, with no dependencies. Egami.Svg uses it for SVG's style sheets, style attributes and presentation attributes; Egap's HTML reader will use it later. It covers the CSS Syntax 3 tokenizer and parser, Selectors 4 matching, the cascade with custom properties and var(), media queries, the box, background, list, table and page properties documents use, @page with its margin boxes, and parsers for colors, lengths, calc(), transforms, fonts and page sizes. Layout and the computed values of each property stay with the consumer: Egami.Css hands back component values, and the consumer reads them with the value parsers.

Egami.Css is at 1.0 (1.0.0) and its API is stable: 1.x versions add to it without breaking code that uses it.

Parsing style sheets

CssStyleSheet.Parse reads a sheet into rules. Parsing never throws on bad CSS: it recovers as CSS Syntax 3 says, and each problem goes to CssParseOptions.Errors as a CssParseError with its line and column. A style rule whose selector does not parse is dropped, as browsers drop it.

// Parsing never throws on bad CSS: it recovers as CSS Syntax 3 says and reports each problem.
var errors = new List<CssParseError>();
var options = new CssParseOptions { Errors = errors.Add };
CssStyleSheet sheet = CssStyleSheet.Parse("""
    @import url(print.css) print;
    @namespace svg url(http://www.w3.org/2000/svg);
    rect.note, svg|circle { fill: gold !important; stroke: #333 }
    @media (max-width: 600px) { rect { stroke-width: 2px } }
    @supports (display: grid) { g { display: grid } }
    @font-face { font-family: "Inter"; src: url(inter.woff2) format("woff2") }
    @layer base { text { fill: gray } }
    @keyframes spin { to { rotate: 1turn } }
    p:unknown { color: red }
    """, options);

foreach (CssRule rule in sheet.Rules)
{
    switch (rule)
    {
        case CssStyleRule style:
            Console.WriteLine($"{style.SelectorText}: {style.Declarations.Count} declarations");
            break;
        case CssMediaRule media:
            Console.WriteLine($"@media {media.Media.MediaText}: {media.Rules.Count} rules");
            break;
        case CssSupportsRule supports:
            Console.WriteLine($"@supports {supports.ConditionText}");
            break;
        case CssFontFaceRule fontFace:
            Console.WriteLine($"@font-face {fontFace.FontFace.Family}");
            break;
        case CssImportRule import:
            Console.WriteLine($"@import {import.Url} for {import.Media.MediaText}");
            break;
        case CssNamespaceRule ns:
            Console.WriteLine($"@namespace {ns.Prefix} = {ns.Uri}");
            break;
        case CssLayerRule layer:
            Console.WriteLine($"@layer {string.Join(", ", layer.Names)}");
            break;
        case CssUnknownAtRule other:
            Console.WriteLine($"@{other.Name} (kept, not interpreted)");
            break;
    }
}

// The rule with ':unknown' was dropped and reported.
CssParseError dropped = errors[0];
Console.WriteLine($"{dropped.Line}:{dropped.Column} {dropped.Message}");

Every rule is a CssRule:

  • CssStyleRule: a selector list and its declarations.
  • CssMediaRule and CssSupportsRule: rules that apply when a media query or a condition holds.
  • CssFontFaceRule: the descriptors of a web font, parsed as a CssFontFace.
  • CssImportRule: a URL Egami.Css does not fetch. Load the sheet yourself and pass it to the cascade.
  • CssNamespaceRule: a prefix for selectors; the sheet collects them in CssStyleSheet.Namespaces.
  • CssLayerRule: cascade layers are not implemented, so the rules inside a layer block count as ordinary rules.
  • CssPageRule: page selectors, the page context's declarations and its CssPageMarginRules (see Pages).
  • CssUnknownAtRule: anything else, such as @keyframes or @counter-style, kept as component values.

Declarations

A CssDeclaration is a property name, its value and the !important flag. CssDeclaration.ParseList reads a style attribute; CssDeclaration.Create makes one declaration, for example from an SVG presentation attribute. Names are lowercased, except custom properties such as --Accent, which keep their case.

// A style attribute, and a declaration made from a presentation attribute such as SVG's fill="red".
IReadOnlyList<CssDeclaration> inline = CssDeclaration.ParseList("color: Red; --Accent: #0af; font-size: 12px !important");
CssDeclaration size = inline[2];
Console.WriteLine($"{size.Name} = {size.ValueText}, important: {size.Important}"); // font-size = 12px, important: True

CssDeclaration fill = CssDeclaration.Create("fill", "red");

Tokens and component values

Values stay close to the syntax. CssTokenizer splits text into CssTokens of a CssTokenKind, each with its line and column. The parser groups them into component values: a CssComponentValue is a CssTokenValue, a CssFunction or a CssSimpleBlock. CssComponentValue.Serialize writes them back as CSS text.

// CSS Syntax 3 tokens, and component values: tokens, functions and simple blocks.
IReadOnlyList<CssToken> tokens = CssTokenizer.Tokenize("rgb(255 0 0 / 50%) 12px");
CssToken first = tokens[0];
bool isFunction = first.Kind == CssTokenKind.Function; // "rgb(" at line 1, column 1

IReadOnlyList<CssComponentValue> values = CssComponentValue.ParseList("calc(100% - 2px) [a] x");
var calc = (CssFunction)values[0];
var block = (CssSimpleBlock)values[2];
var word = (CssTokenValue)values[4];
string text = CssComponentValue.Serialize(values); // "calc(100% - 2px) [a] x"

Selectors

Selectors match a document tree through ICssElement. Egami.Svg's elements implement it, and an HTML DOM will. An element gives its name and namespace, its parent and siblings, its ID, classes and attributes (as CssAttributes). Optional members cover HTML's case rules, the language for :lang(), the direction for :dir() and states such as :hover or :checked.

// A document tree implements ICssElement; Egami.Svg's elements do. The members must not allocate in the common case.
public sealed class Element(string localName, string? namespaceUri = "http://www.w3.org/2000/svg") : ICssElement
{
    private readonly List<Element> _children = [];
    private readonly List<CssAttribute> _attributes = [];

    public string LocalName { get; } = localName;
    public string? NamespaceUri { get; } = namespaceUri;
    public Element? ParentElement { get; private set; }
    public ICssElement? Parent => ParentElement;
    public ICssElement? PreviousSibling => Sibling(-1);
    public ICssElement? NextSibling => Sibling(1);
    public ICssElement? FirstChild => _children.Count > 0 ? _children[0] : null;
    public bool IsEmpty => _children.Count == 0;
    public string? Id => GetAttribute("id", null);
    public IEnumerable<CssAttribute> Attributes => _attributes;

    public bool HasClass(string name) => GetAttribute("class", null)?.Split(' ').Contains(name) == true;

    public string? GetAttribute(string localName, string? namespaceUri) =>
        _attributes.Find(a => a.LocalName == localName && a.NamespaceUri == namespaceUri) is { LocalName: not null } a ? a.Value : null;

    public Element Set(string name, string value)
    {
        _attributes.Add(new CssAttribute(null, name, value));
        return this;
    }

    public Element Add(Element child)
    {
        child.ParentElement = this;
        _children.Add(child);
        return this;
    }

    private Element? Sibling(int step)
    {
        if (ParentElement is null)
        {
            return null;
        }

        int index = ParentElement._children.IndexOf(this) + step;
        return index >= 0 && index < ParentElement._children.Count ? ParentElement._children[index] : null;
    }
}

CssSelectorList.Parse reads a selector list; TryParse does the same without throwing. Each CssSelector has its CssSpecificity and its pseudo-element, if any. Prefixes such as svg|rect need a CssNamespaces. Matching runs right to left and does not allocate, so files with thousands of elements stay fast.

// Selectors 4, matched right to left over ICssElement.
CssSelectorList list = CssSelectorList.Parse("g.layer > rect:first-child, :is(circle, ellipse)");
bool matches = list.Matches(rect);

CssSelector selector = list.Selectors[0];
CssSpecificity specificity = selector.Specificity; // (0, 2, 2)

// Namespace prefixes, as @namespace declares them.
var namespaces = new CssNamespaces();
namespaces.Set("svg", "http://www.w3.org/2000/svg");
bool inSvg = CssSelectorList.Parse("svg|rect", namespaces).Matches(rect);

// A pseudo-element never matches the element itself; the cascade asks for it by name.
CssSelector before = CssSelectorList.Parse("rect::before").Selectors[0];
bool beforeMatches = before.MatchesPseudoElement(rect, "before");

bool valid = CssSelectorList.TryParse("rect:nonsense", out _); // false: unknown pseudo-classes are invalid

Egami.Css supports the Selectors 4 set: type, universal and namespaces; IDs, classes and every attribute operator with the i and s flags; the four combinators; the structural pseudo-classes, including :nth-child(An+B of S); :not(), :is(), :where() and :has(); :lang() with wildcards, :dir(), :scope and :defined; and the pseudo-elements. An unknown pseudo-class makes a selector invalid.

The cascade and computed styles

CssCascade takes the sheets in document order, each with a CssOrigin, and CssCascadeOptions: the media context, a callback for @supports, and a CssPropertyRegistry. It indexes the rules by ID, class and type, so each element tests few rules.

CssCascade.Cascade gives an element's CssCascadedStyle: the winning declaration of each property, by origin and importance, the style attribute, specificity and order. Presentation hints count as author declarations of specificity zero, before every author rule. Shorthands are expanded first, so font in a sheet and font-size in a hint compete per longhand.

CssCascade.Compute turns it into a CssComputedStyle: it resolves inherit, initial, unset and revert, applies inheritance, and substitutes var() with fallbacks. Properties in a reference cycle become invalid. A computed style stores only what its element sets and looks the rest up in its parent.

The registry says which properties inherit and what their initial values are. CssPropertyRegistry.CreateDefault knows the common text properties of CSS. Consumers add their own with a CssPropertyDefinition, and shorthands with a CssShorthandDefinition. A definition can validate values, so an invalid declaration is dropped and an earlier one wins.

// The registry says which properties inherit and their initial values; consumers add their own.
CssPropertyRegistry registry = CssPropertyRegistry.CreateDefault();
registry.Register(new CssPropertyDefinition("fill", Inherited: true, InitialValue: "black")
{
    Validate = value => CssColor.TryParse(value, out _),
});
registry.RegisterShorthand(new CssShorthandDefinition("marker", ["marker-start", "marker-end"],
    value => [new CssDeclaration("marker-start", value), new CssDeclaration("marker-end", value)]));

CssStyleSheet userAgent = CssStyleSheet.Parse("rect { fill: gray }");
CssStyleSheet author = CssStyleSheet.Parse("""
    svg { --accent: teal; font: 12px sans-serif }
    .layer { fill: var(--accent) }
    #r { fill: inherit; opacity: 0.5 }
    """);
var cascade = new CssCascade(
    [(userAgent, CssOrigin.UserAgent), (author, CssOrigin.Author)],
    new CssCascadeOptions { Registry = registry, Media = new CssMediaContext { Width = 800 } });

// Cascade each element (presentation attributes as hints, the style attribute as inline style), then compute it
// from its parent's computed style.
CssComputedStyle rootStyle = cascade.Compute(cascade.Cascade(root), null);
CssComputedStyle groupStyle = cascade.Compute(cascade.Cascade(group), rootStyle);
CssCascadedStyle cascaded = cascade.Cascade(rect,
    presentationHints: [CssDeclaration.Create("fill", "red")],
    inlineStyle: CssDeclaration.ParseList("stroke: blue"));
CssComputedStyle style = cascade.Compute(cascaded, groupStyle);

cascaded.TryGetDeclaration("fill", out CssDeclaration? winner, out CssOrigin origin); // fill: inherit, Author
string? fill = style.GetValueText("fill");           // "teal": inherited, var() substituted
string? fontSize = style.GetValueText("font-size");  // "12px": from the font shorthand, inherited
if (style.TryGetValue("opacity", out IReadOnlyList<CssComponentValue>? opacity))
{
    // The consumer parses each property by its type.
}

Box, list and table properties

CssPropertyRegistry.CreateDefault also knows the properties a document's boxes need, each with its value grammar, so an invalid value is dropped and an earlier valid one wins:

  • Boxes: margin, padding, border and their sides, border-width, border-style, border-color, border-radius, width, height with their min- and max- forms, and box-sizing.
  • Backgrounds: the background-* longhands, which hold one comma-separated item per layer, and the background shorthand.
  • Lists: list-style and its longhands. Tables: border-collapse, border-spacing, caption-side, empty-cells, table-layout and vertical-align.
  • Flow: float, clear, and overflow with overflow-x and overflow-y.
  • Fragmentation: break-before, break-after, break-inside, orphans, widows, box-decoration-break and page. The legacy page-break-before, page-break-after and page-break-inside are shorthands of the break-* properties, so always becomes page.
  • Generated content: content, counter-reset, counter-increment and counter-set, kept as written.

Shorthands expand into longhands; what a shorthand leaves out is reset to its initial value, as CSS says. The HTML default style sheet and what each property means for layout stay with the consumer.

// The default registry knows the box, background, list, table, float, overflow and break properties: shorthands
// expand into longhands, and an invalid value is dropped so an earlier one wins.
CssStyleSheet sheet = CssStyleSheet.Parse("""
    .layer { list-style: square inside; border-collapse: collapse }
    rect {
      margin: 1em auto; padding: 2px 4px;
      border: 1px solid; border-left: thick double red;
      background: url(paper.png) no-repeat center / cover, ivory;
      float: left; overflow: hidden auto;
      page-break-before: always; break-inside: avoid;
      width: 50%; width: wide;
    }
    """);
var cascade = new CssCascade([(sheet, CssOrigin.Author)]);
CssComputedStyle groupStyle = cascade.Compute(cascade.Cascade(group), cascade.Compute(cascade.Cascade(root), null));
CssComputedStyle style = cascade.Compute(cascade.Cascade(rect), groupStyle);

string? marginLeft = style.GetValueText("margin-left");        // "auto"
string? borderLeft = style.GetValueText("border-left-style");  // "double"
string? borderTop = style.GetValueText("border-top-color");    // "currentcolor": left out, so initial
string? images = style.GetValueText("background-image");       // "url(paper.png), none": one item per layer
string? color = style.GetValueText("background-color");        // "ivory"
string? breakBefore = style.GetValueText("break-before");      // "page": the legacy alias mapped
string? width = style.GetValueText("width");                   // "50%": "wide" is not a width
string? listType = style.GetValueText("list-style-type");      // "square": inherited

Pages

@page rules style the pages of paged media (CSS Paged Media 3). A CssPageRule has CssPageSelectors, each a page type name and CssPagePseudoClasses (:first, :left, :right, :blank) with its specificity, the declarations for the page context, and CssPageMarginRules for the sixteen CssMarginBoxes around the page, such as @top-center.

CssCascade.CascadePage takes a CssPage (the page type and whether the page is the first, a left or a blank one) and gives a CssPageStyle: the page context's cascaded style and one for each margin box a matching rule styles. Rules compete by origin and importance, then by page selector specificity and order. Compute the page context with the root element's computed style as its parent, and each margin box with the page context's. CssPageSize reads the size descriptor: auto, one or two lengths, or a name such as A4 or letter with portrait or landscape. The registry also knows page-orientation, marks and bleed.

CssStyleSheet sheet = CssStyleSheet.Parse("""
    @page { size: A4; margin: 2cm; font: 9pt serif;
      @bottom-center { content: counter(page) } }
    @page :first { margin-top: 5cm; @bottom-center { content: none } }
    @page :left { margin-left: 3cm }
    @page appendix { size: A4 landscape }
    h1.appendix { page: appendix; break-before: right }
    """);
CssPageRule first = (CssPageRule)sheet.Rules[1];
CssPageSelector selector = first.Selectors[0];               // :first, specificity (0, 1, 0)
CssPageMarginRule footer = ((CssPageRule)sheet.Rules[0]).MarginRules[0]; // CssMarginBox.BottomCenter

// Cascade the page rules for each page the layout makes, then compute the page context from the root element's
// computed style and each margin box from the page context's.
var cascade = new CssCascade([(sheet, CssOrigin.Author)]);
CssPageStyle pageStyle = cascade.CascadePage(new CssPage(Name: null, IsFirst: false, IsLeft: true));
CssComputedStyle root = cascade.Compute(new CssCascadedStyle(), null);
CssComputedStyle page = cascade.Compute(pageStyle.Page, root);
string? marginLeft = page.GetValueText("margin-left");                // "3cm"
bool isSize = CssPageSize.TryParse(page.GetValueText("size")!, out CssPageSize a4); // 210mm by 297mm
CssComputedStyle box = cascade.Compute(pageStyle.MarginBoxes[CssMarginBox.BottomCenter], page);
string? content = box.GetValueText("content");                        // "counter(page)"
string? fontSize = box.GetValueText("font-size");                     // "9pt": inherited from the page context

// A named page: the appendix pages turn A4 to landscape.
CssPageStyle appendix = cascade.CascadePage(new CssPage("appendix", IsFirst: false, IsLeft: false));
bool isTurned = CssPageSize.TryParse(appendix.Page.GetValueText("size")!, out CssPageSize turned);
bool landscape = turned.Orientation == CssPageOrientation.Landscape;  // true: 297mm by 210mm

Media queries

CssMediaQueryList evaluates Media Queries 4 against a CssMediaContext: the media type, the viewport, the resolution and the user's preferences. It knows not, only, and and or, min- and max- prefixes and the range syntax. A query that does not parse becomes not all without affecting the others; a feature the context does not know goes to its hook, or counts as false.

// Media Queries 4: types, features, min-/max- and range syntax. Unknown features count as false.
var phone = new CssMediaContext { Width = 390, Height = 844, Resolution = 3, PrefersColorScheme = "dark" };
bool narrow = CssMediaQueryList.Parse("screen and (width < 600px)").Matches(phone);
bool dark = CssMediaQueryList.Parse("(prefers-color-scheme: dark) and (min-resolution: 2dppx)").Matches(phone);

// Features the context does not know go to a hook.
CssMediaContext touch = phone with { Feature = (name, value) => name == "pointer" ? value?[0].ToString() == "coarse" : null };
bool coarse = CssMediaQueryList.Parse("(pointer: coarse)").Matches(touch);

Value parsers

Each parser has TryParse over text and over component values.

CssColor reads every CSS Color 4 syntax: the named and system colors, hex, rgb(), hsl(), hwb(), lab(), lch(), oklab(), oklch() and color(). It converts to sRGB and clips to its gamut. Level says whether the text used CSS Color 3 or 4 syntax, for consumers that follow an older specification.

// Every CSS Color 4 syntax, converted to sRGB (0 to 1) and clipped to its gamut.
bool isColor = CssColor.TryParse("oklch(62.8% 0.258 29.2 / 50%)", out CssColor red);
(byte r, byte g, byte b, byte a) = red.ToRgba8();         // about (255, 0, 0, 128)
bool newer = red.Level == 4;                             // SVG 1.1 knows only level 3

bool isCurrent = CssColor.TryParse("currentColor", out CssColor current);  // current.IsCurrentColor: use the 'color' property

CssLength is a number with a CssUnit. CssAngle converts to degrees. CssUrl reads url() and src(). CssCalc reads calc(), min(), max() and clamp(); the consumer resolves it by saying what each unit is worth.

bool isLength = CssLength.TryParse("1.5em", out CssLength length);       // (1.5, CssUnit.Em)
new CssLength(1, CssUnit.In).TryToPixels(out double px);  // 96
bool isAngle = CssAngle.TryParse("0.25turn", out CssAngle angle);        // 90 degrees
bool isUrl = CssUrl.TryParse("url('pattern.svg#dots')", out CssUrl url);

// calc(), min(), max() and clamp(): the consumer says what each unit is worth.
bool isCalc = CssCalc.TryParse("calc(100% - 2em)", out CssCalc? calc);
double width = calc!.Resolve(unit => unit switch
{
    CssUnit.Percent => 3,   // a percent of a 300px box
    CssUnit.Em => 16,
    CssUnit.Px => 1,
    _ => double.NaN,
});                                                       // 268

CssTransformList reads the CSS transform property into CssTransformFunctions: CssMatrixTransform, CssTranslateTransform, CssScaleTransform, CssRotateTransform and CssSkewTransform. 3D functions become CssUnsupportedTransform and the matrix ignores them. CssTransformOrigin reads transform-origin.

// A CSS transform list, its matrix, and the functions it holds.
bool isTransform = CssTransformList.TryParse("translate(10px, 50%) rotate(45deg) scale(2)", out CssTransformList? css);
Matrix3x2 matrix = css!.ToMatrix((length, isX) => length.Unit == CssUnit.Percent ? length.Value * (isX ? 2 : 1) : length.Value);

foreach (CssTransformFunction function in css.Functions)
{
    string kind = function switch
    {
        CssTranslateTransform t => $"translate {t.X} {t.Y}",
        CssRotateTransform r => $"rotate {r.Degrees} about ({r.CenterX}, {r.CenterY})",
        CssScaleTransform s => $"scale {s.X} {s.Y}",
        CssSkewTransform k => $"skew {k.XDegrees} {k.YDegrees}",
        CssMatrixTransform m => $"matrix {m.A} {m.B} {m.C} {m.D} {m.E} {m.F}",
        CssUnsupportedTransform u => $"{u.Name} (3D, ignored)",
        _ => "",
    };
}

bool isOrigin = CssTransformOrigin.TryParse("left bottom", out CssTransformOrigin origin); // (0%, 100%, 0)

CssFont reads the font shorthand. CssFontFamilyList holds CssFontFamily names and generic families. CssFontWeight resolves bolder and lighter against the parent's weight, as its CssFontWeightKind says. CssFontFace reads @font-face: its CssFontSources, its weight and width as a CssRange, and its CssUnicodeRanges.

bool isFont = CssFont.TryParse("italic bold 14px/1.4 \"Source Sans 3\", sans-serif", out CssFont? font);
CssFontWeight weight = font!.Weight;                     // 700
foreach (CssFontFamily family in font.Families!.Families)
{
    Console.WriteLine($"{family.Name} (generic: {family.IsGeneric})");
}

bool isFamilyList = CssFontFamilyList.TryParse("Georgia, serif", out CssFontFamilyList? families);
bool isWeight = CssFontWeight.TryParse("bolder", out CssFontWeight bolder);
double resolved = bolder.Kind == CssFontWeightKind.Bolder ? bolder.Resolve(400) : bolder.Value; // 700

// @font-face descriptors.
CssStyleSheet sheet = CssStyleSheet.Parse("""
    @font-face {
      font-family: "Inter";
      src: local("Inter"), url(inter.woff2) format("woff2") tech(variations);
      font-weight: 100 900;
      unicode-range: U+0000-00FF, U+20AC;
    }
    """);
CssFontFace face = ((CssFontFaceRule)sheet.Rules[0]).FontFace;
CssFontSource source = face.Sources[1];                  // inter.woff2, format woff2, tech variations
CssRange weights = face.Weight!.Value;                   // 100 to 900
CssUnicodeRange latin = face.UnicodeRanges[0];           // U+0-FF

Not supported

  • CSS Nesting: rules nested in a style rule are dropped and reported.
  • Cascade layers: layer blocks count as ordinary rules.
  • @import is not fetched, and animations and transitions are not part of the cascade.
  • @property, @container, @scope, @keyframes, @counter-style and similar rules are kept as unknown at-rules.
  • Logical properties such as margin-inline-start and inset, and positioning (position, top, z-index), are not in the default registry: they cascade as unregistered properties, without a shorthand or a link to the physical sides.
  • Page selectors take :first, :left, :right and :blank, not :nth() or page groups.
  • Shadow DOM: :host is invalid, and ::part() and ::slotted() parse but never match. :state() and Selectors 5's :heading are invalid.
  • CSS Color 5: color-mix(), relative colors and device-cmyk(). Colors outside sRGB are clipped, not gamut-mapped.
  • calc() covers lengths, percentages and numbers, not angles or calc() inside transforms.
  • Selectors keep their text as written, not CSSOM's canonical serialization.