CSS in librsvg is now in Rust, courtesy of Mozilla Servo

- gnome, librsvg, rust

Summary: after an epic amount of refactoring, librsvg now does all CSS parsing and matching in Rust, without using libcroco. In addition, the CSS engine comes from Mozilla Servo, so it should be able to handle much more complex CSS than librsvg ever could before.

This is the story of CSS support in librsvg.

Introduction

The first commit to introduce CSS parsing in librsvg dates from 2002. It was as minimal as possible, written to support a small subset of what was then CSS2.

Librsvg handled CSS stylesheets more "piecing them apart" than "parsing them". You know, when g_strsplit() is your best friend. The basic parsing algorithm was to turn a stylesheet like this:

rect { fill: blue; }

.classname {
    fill: green;
    stroke-width: 4;
}

Into a hash table whose keys are strings like rect and .classname, and whose values are everything inside curly braces.

The selector matching phase was equally simple. The code only handled a few possible match types as follows. If it wanted to match a certain kind of CSS selector, it would say, "what would this selector look like in CSS syntax", it would make up a string with that syntax, and compare it to the key strings it had stored in the hash table from above.

So, to match an element name selector, it would sprintf("%s", element->name), obtain something like rect and see if the hash table had such a key.

To match a class selector, it would sprintf(".%s", element->class), obtain something like .classname, and look it up in the hash table.

This scheme supported only a few combinations. It handled tag, .class, tag.class, and a few combinations with #id in them. This was enough to support very simple stylesheets.

The value corresponding to each key in the hash table was the stuff between curly braces in the stylesheet, so the second rule from the example above would contain fill: green; stroke-width: 4;. Once librsvg decided that an SVG element matched that CSS rule, it would re-parse the string with the CSS properties and apply them to the element's style.

I'm amazed that so little code was enough to deal with a good number of SVG files with stylesheets. I suspect that this was due to a few things:

  • While people were using complex CSS in HTML all the time, it was less common for SVG...

  • ... because CSS2 was somewhat new, and the SVG spec was still being written...

  • ... and SVGs created with illustration programs don't really use stylesheets; they include the full style information inside each element instead of symbolically referencing it from a stylesheet.

From the kinds of bugs that librsvg has gotten around "CSS support is too limited", it feels like SVGs which use CSS features are either hand-written, or machine-generated from custom programs like data plotting software. Illustration programs tend to list all style properties explicitly in each SVG element, and don't use CSS.

Libcroco appears

The first commit to introduce libcroco was to do CSS parsing, from March 2003.

At the same time, libcroco was introducing code to do CSS matching. However, this code never got used in librsvg; it still kept its simple string-based matcher. Maybe libcroco's API was not ready?

Libcroco fell out of maintainership around the first half of 2005, and volunteers have kept fixing it since then.

Problems with librsvg's string matcher for CSS

The C implementation of CSS matching in librsvg remained basically untouched until 2018, when Paolo Borelli and I started porting the surrounding code to Rust.

I had a lot of trouble figuring out the concepts from the code. I didn't know all the terminology of CSS implementations, and librsvg didn't use it, either.

I think that librsvg's code suffered from what the refactoring literature calls primitive obsession. Instead of having a parsed representation of CSS selectors, librsvg just stored a stringified version of them. So, a selector like rect#classname really was stored with a string like that, instead of an actual decomposition into structs.

Moreover, things were misnamed. This is the field that stored stylesheet data inside an RsvgHandle:

    GHashTable *css_props;

From just looking at the field declaration, this doesn't tell me anything about what kind of data is stored there. One has to grep the source code for where that field is used:

static void
rsvg_css_define_style (RsvgHandle * ctx,
                       const gchar * selector,
                       const gchar * style_name,
                       const gchar * style_value,
                       gboolean important)
{
    GHashTable *styles;

    styles = g_hash_table_lookup (ctx->priv->css_props, selector);

Okay, it looks up a selector by name in the css_props, and it gives back... another hash table styles? What's in there?

        g_hash_table_insert (styles,
                             g_strdup (style_name),
                             style_value_data_new (style_value, important));

Another string key called style_name, whose key is a StyleValueData; what's in it?

typedef struct _StyleValueData {
    gchar *value;
    gboolean important;
} StyleValueData;

The value is another string. Strings all the way!

At the time, I didn't really figure out what each level of nested hash tables was supposed to mean. I didn't understand why we handled style properties in a completely different part of the code, and yet this part had a css_props field that didn't seem to store properties at all.

It took a while to realize that css_props was misnamed. It wasn't storing a mapping of selector names to properties; it was storing a mapping of selector names to declaration lists, which are lists of property/value pairs.

So, when I started porting the CSS parsing code to Rust, I started to create real types with for each concept.

// Maps property_name -> Declaration
type DeclarationList = HashMap<String, Declaration>;

pub struct CssStyles {
    selectors_to_declarations: HashMap<String, DeclarationList>,
}

Even though the keys of those HashMaps are still strings, because librsvg didn't have a better way to represent their corresponding concepts, at least those declarations let one see what the hell is being stored without grepping the rest of the code. This is a part of the code that I didn't really touch very much, so it was nice to have that reminder.

The first port of the CSS matching code to Rust kept the same algorithm as the C code, the one that created strings with element.class and compared them to the stored selector names. Ugly, but it still worked in the same limited fashion.

Rustifying the CSS parsers

It turns out that CSS parsing is divided in two parts. One can have a style attribute inside an element, for example

<rect x="0" y="0" width="100" height="100"
      style="fill: green; stroke: magenta; stroke-width: 4;"/>

This is a plain declaration list which is not associated to any selectors, and which is applied directly to just the element in which it appears.

Then, there is the <style> element itself, with a normal-looking CSS stylesheet

<style type="text/css">
  rect {
    fill: green;
    stroke: magenta;
    stroke-width: 4;
  }
</style>

This means that all <rect> elements will get that style applied.

I started to look for existing Rust crates to parse and handle CSS data. The cssparser and selectors crates come from Mozilla, so I thought they should do a pretty good job of things.

And they do! Except that they are not a drop-in replacement for anything. They are what gets used in Mozilla's Servo browser engine, so they are optimized to hell, and the code can be pretty intimidating.

Out of the box, cssparser provides a CSS tokenizer, but it does not know how to handle any properties/values in particular. One must use the tokenizer to implement a parser for each kind of CSS property one wants to support — Servo has mountains of code for all of HTML's style properties, and librsvg had to provide a smaller mountain of code for SVG style properties.

Thus started the big task of porting librsvg's string-based parsers for CSS properties into ones based on cssparser tokens. Cssparser provides a Parser struct, which extracts tokens out of a CSS stream. Out of this, librsvg defines a Parse trait for parsable things:

use cssparser::Parser;

pub trait Parse: Sized {
    type Err;

    fn parse(parser: &mut Parser<'_, '_>) -> Result<Self, Self::Err>;
}

What's with those two default lifetimes in Parser<'_, '_>? Cssparser tries very hard to be a zero-copy tokenizer. One of the lifetimes refers to the input string which is wrapped in a Tokenizer, which is wrapped in a ParserInput. The other lifetime is for the ParserInput itself.

In the actual implementation of that trait, the Err type also uses the lifetime that refers to the input string. For example, there is a BasicParseErrorKind::UnexpectedToken(Token<'i>), which one returns when there is an unexpected token. And to avoid copying the substring into the error, one returns a slice reference into the original string, thus the lifetime.

I was more of a Rust newbie back then, and it was very hard to make sense of how cssparser was meant to be used.

The process was more or less this:

  • Port the C parsers to Rust; implement types for each CSS property.

  • Port the &str-based parsers into ones that use cssparser.

  • Fix the error handling scheme to match what cssparser's high-level traits expect.

This last point was... hard. Again, I wasn't comfortable enough with Rust lifetimes and nested generics; in the end it was all right.

Moving declaration lists to Rust

With the individual parsers for CSS properties done, and with them already using a different type for each property, the next thing was to implement cssparser's traits to parse declaration lists.

Again, a declaration list looks like this:

fill: blue;
stroke-width: 4;

It's essentially a key/value list.

The trait that cssparser wants us to implement is this:

pub trait DeclarationParser<'i> {
    type Declaration;
    type Error: 'i;

    fn parse_value<'t>(
        &mut self,
        name: CowRcStr<'i>,
        input: &mut Parser<'i, 't>,
    ) -> Result<Self::Declaration, ParseError<'i, Self::Error>>;
}

That is, define a type for a Declaration, and implement a parse_value() method that takes a name and a Parser, and outputs a Declaration or an error.

What this really means is that the type you implement for Declaration needs to be able to represent all the CSS property types that you care about. Thus, a struct plus a big enum like this:

pub struct Declaration {
    pub prop_name: String,
    pub property: ParsedProperty,
    pub important: bool,
}

pub enum ParsedProperty {
    BaselineShift(SpecifiedValue<BaselineShift>),
    ClipPath(SpecifiedValue<ClipPath>),
    ClipRule(SpecifiedValue<ClipRule>),
    Color(SpecifiedValue<Color>),
    ColorInterpolationFilters(SpecifiedValue<ColorInterpolationFilters>),
    Direction(SpecifiedValue<Direction>),
    ...
}

This gives us declaration lists (the stuff inside curly braces in a CSS stylesheet), but it doesn't give us qualified rules, which are composed of selector names plus a declaration list.

Refactoring towards real CSS concepts

Paolo Borelli has been steadily refactoring librsvg and fixing things like the primitive obsession I mentioned above. We now have real concepts like a Document, Stylesheet, QualifiedRule, Rule, AtRule.

This refactoring took a long time, because it involved redoing the XML loading code and its interaction with the CSS parser a few times.

Implementing traits from the selectors crate

The selectors crate contains Servo's code for parsing CSS selectors and doing matching. However, it is extremely generic. Using it involves implementing a good number of concepts.

For example, this SelectorImpl trait has no methods, and is just a collection of types that refer to your implementation of an element tree. How do you represent an attribute/value? How do you represent an identifier? How do you represent a namespace and a local name?

pub trait SelectorImpl {
    type ExtraMatchingData: ...;
    type AttrValue: ...;
    type Identifier: ...;
    type ClassName: ...;
    type PartName: ...;
    type LocalName: ...;
    type NamespaceUrl: ...;
    type NamespacePrefix: ...;
    type BorrowedNamespaceUrl: ...;
    type BorrowedLocalName: ...;
    type NonTSPseudoClass: ...;
    type PseudoElement: ...;
}

A lot of those can be String, but Servo has smarter things in store. I ended up using the markup5ever crate, which provides a string interning framework for markup and XML concepts like a LocalName, a Namespace, etc. This reduces memory consumption, because instead of storing string copies of element names everywhere, one just stores tokens for interned strings.

(In the meantime I had to implement support for XML namespaces, which the selectors code really wants, but which librsvg never supported.)

Then, the selectors crate wants you to say how your code implements an element tree. It has a monster trait Element:

pub trait Element {
    type Impl: SelectorImpl;

    fn opaque(&self) -> OpaqueElement;

    fn parent_element(&self) -> Option<Self>;

    fn parent_node_is_shadow_root(&self) -> bool;

    ...

    fn prev_sibling_element(&self) -> Option<Self>;
    fn next_sibling_element(&self) -> Option<Self>;

    fn has_local_name(
        &self, 
        local_name: &<Self::Impl as SelectorImpl>::BorrowedLocalName
    ) -> bool;

    fn has_id(
        &self,
        id: &<Self::Impl as SelectorImpl>::Identifier,
        case_sensitivity: CaseSensitivity,
    ) -> bool;

    ...
}

That is, when you provide an implementation of Element and SelectorImpl, the selectors crate will know how to navigate your element tree and ask it questions like, "does this element have the id #foo?"; "does this element have the name rect?". It makes perfect sense in the end, but it is quite intimidating when you are not 100% comfortable with webs of traits and associated types and generics with a bunch of trait bounds!

I tried implementing that trait twice in the last year, and failed. It turns out that its API needed a key fix that landed last June, but I didn't notice until a couple of weeks ago.

So?

Two days ago, Paolo and I committed the last code to be able to completely replace libcroco.

And, after implementing CSS specificity (which was easy now that we have real CSS concepts and a good pipeline for the CSS cascade), a bunch of very old bugs started falling down (1 2 3 4 5 6).

Now it is going to be easy to implement things like letting the application specify a user stylesheet. In particular, this should let GTK remove the rather egregious hack it has to recolor SVG icons while using librsvg indirectly.

Conclusion

This will appear in librsvg 2.47.1 — that version will no longer require libcroco.

As far as I know, the only module that still depends on libcroco (in GNOME or otherwise) is gnome-shell. It uses libcroco to parse CSS and get the basic structure of selectors so it can implement matching by hand.

Gnome-shell has some code which looks awfully similar to what librsvg had when it was written in C:

  • StTheme has the high-level CSS stylesheet parser and the selector matching code.

  • StThemeNode has the low-level CSS property parsers.

... and it turns out that those files come all the way from HippoCanvas, the CSS-aware canvas that Mugshot used! Mugshot was a circa-2006 pre-Facebook aggregator for social media data like blogs, Flickr pictures, etc. HippoCanvas also got used in Sugar, the GUI for One Laptop Per Child. Yes, our code is that old.

Libcroco is unmaintained, and has outstanding CVEs. I would be very happy to assist someone in porting gnome-shell's CSS code to Rust :)