CSS for SVG over a ztree document: the style attribute, selectors and the cascade.
  • Zig 96.5%
  • Nix 3.5%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Jeffrey C. Ollie 4d9a4d8550
All checks were successful
test / test (push) Successful in 10m21s
test / docs (push) Successful in 1m54s
Extract the CSS reader from zig-svg
SVG 1.1 §6 is CSS, and reading it is a job with one right answer that
does not depend on SVG at all: which of several declarations of one
property applies to an element. That is what this is. It came out of
zig-svg, where it was `src/style.zig` and `src/css.zig`, and it is
separated because nothing in it is about drawing.

The split it keeps is the useful one. There is no layout here, no box
model, no computed values and no property table -- a value comes back as
the document wrote it, for whatever reads `fill` or `stop-color` to
parse. So a consumer needs no agreement with this library about what any
property *is*, only about which of several spellings of it won.

Two halves, usable apart. `declaration.zig` is one block -- the `style`
attribute -- and needs no tree, no allocator and no stylesheet.
`stylesheet.zig` is the rest: CSS 2 selectors, the four combinators,
and the five bands of CSS 2.1 §6.4.3 with SVG 1.1 §6.4's addition at the
bottom. Both read a block with the same code, so a property means the
same thing in either by construction rather than by two parsers
agreeing.

What is refused rather than skipped: at-rules, pseudo-classes,
pseudo-elements, namespace selectors, and the CSS 3 attribute operators.
`@import` could not have been implemented in any case -- fetching a
stylesheet is I/O, and there is none here, which is what lets a renderer
built on this run in a process that cannot open anything.

New here, rather than carried over: `tests/fuzz.zig` and the loop that
drives it. Three targets -- a stylesheet alone, one declaration block,
and a whole document with the cascade run against every element -- and
the properties they hold to. Every slice a parsed selector holds is
checked to point inside the text it was parsed from, which is the claim
worth making about a parser that copies nothing. The allocation-failure
mode found a leak in the document target's own bookkeeping the first
time it ran.

22 tests, `zig fmt --check`, `reuse lint` and `zig build check` clean.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01Fv1ixLGPVnP6vse6WonSBo
2026-09-19 13:26:16 -05:00
.forgejo/workflows Extract the CSS reader from zig-svg 2026-09-19 13:26:16 -05:00
LICENSES Extract the CSS reader from zig-svg 2026-09-19 13:26:16 -05:00
src Extract the CSS reader from zig-svg 2026-09-19 13:26:16 -05:00
tests Extract the CSS reader from zig-svg 2026-09-19 13:26:16 -05:00
tools Extract the CSS reader from zig-svg 2026-09-19 13:26:16 -05:00
.gitignore Extract the CSS reader from zig-svg 2026-09-19 13:26:16 -05:00
build.zig Extract the CSS reader from zig-svg 2026-09-19 13:26:16 -05:00
build.zig.zon Extract the CSS reader from zig-svg 2026-09-19 13:26:16 -05:00
flake.lock Extract the CSS reader from zig-svg 2026-09-19 13:26:16 -05:00
flake.nix Extract the CSS reader from zig-svg 2026-09-19 13:26:16 -05:00
README.md Extract the CSS reader from zig-svg 2026-09-19 13:26:16 -05:00
REUSE.toml Extract the CSS reader from zig-svg 2026-09-19 13:26:16 -05:00

zig-css

CSS for SVG, over a ztree document, for Zig 0.16: the style attribute, a <style> element's rules, the selectors that choose them, and the cascade that decides between them.

The API documentation is generated from the doc comments, which is where most of the explanation lives.

const css = @import("css");

var sheet = try css.parse(gpa, &.{"rect { fill: red }"});
defer sheet.deinit();

// The whole of the cascade in one call: the `style` attribute, the
// stylesheet, then the presentation attribute, with `!important` on top.
const fill = css.property(&sheet, tree, node, "fill");

What it is, and what it is not

It is what SVG 1.1 §6 normatively references, which is CSS 2, and only the part of CSS 2 that chooses which declarations apply to which element.

There is no layout here, no box model, no computed values, and no property table. A value comes back as the document wrote it, for whatever reads fill or stop-color to parse. That division is the whole design: deciding which declaration wins is a job with one right answer that every renderer needs and nobody enjoys writing twice, while deciding what fill means belongs to the thing being styled. A consumer therefore needs no agreement with this library about what any property is — only about which of several spellings of it won.

It performs no I/O. Everything it needs arrives as memory, which is what lets a renderer built on it run in a process that cannot open anything.

Two halves, usable apart

css.declaration is one declaration block — the style attribute of §6.3 — and needs no tree, no allocator and no stylesheet:

const d = css.declaration.find("fill: red !important", "fill").?;
// d.value == "red", d.important == true

The rest is the sheet. css.parse reads the text of every <style> element into one Stylesheet, css.matches answers whether a selector picks an element, and css.property runs the cascade. Both halves read a block with the same code, so a property means the same thing in either by construction rather than by two parsers agreeing.

Selectors

* any element
rect a type name, matched case-sensitively — this is XML, and RECT is not rect
.warm a class, out of the whitespace-separated class attribute
#logo an id
[data-k], [data-k=v], [data-k~=v], [data-k|=v] CSS 2's attribute selectors and presence
g rect a descendant
g > rect a child
text + rect the next element sibling
text ~ rect any later element sibling
a, b, c a list

Compounds combine freely: rect.warm#logo[data-k~=v] asks all five questions of one element.

A selector is stored subject first, because that is the order matching walks in: the subject is the element in hand and everything after it is a question about the tree around it. A descendant combinator backtracks, which is not optional — g text g rect against a tree whose nearest <g> is the wrong one is a match that a greedy walk reports as a miss.

The cascade

CSS 2.1 §6.4.3 orders declarations of one property and SVG 1.1 §6.4 adds the presentation attributes at the bottom of it. Written out for the one origin a standalone SVG has — the author's — it comes to five bands:

  1. !important in a style attribute
  2. !important in a rule, by specificity
  3. a style attribute
  4. a rule, by specificity and then by source order
  5. a presentation attribute

The style attribute outranks every selector because CSS gives it a specificity higher than any of them can reach, and the presentation attributes lose to everything because §6.4 says so in as many words.

All five bands are css.property, and that is the point of it: a consumer that reads a presentation property any other way reads one the cascade silently did not reach. In the renderer this was extracted from, stop-color was read straight off the attribute for exactly that reason, so <stop style="stop-color:red"> — which is how Inkscape writes every gradient it saves — was being ignored.

Every <style> element of a document is one sheet in document order, which is what breaks a tie between two of them. A <style> whose type is not CSS is passed over, because its content is not a stylesheet at all.

What it refuses

At-rules, pseudo-classes, pseudo-elements, namespace selectors and the CSS 3 attribute operators are refused, not skipped. A rule that was meant to apply and silently did not is worse than a document that says plainly it cannot be drawn.

@import is refused for a second reason as well: fetching a stylesheet is I/O, and there is none here.

A malformed declaration is the one thing that is skipped rather than refused — style="nonsense;fill:blue" is blue — because CSS 2.1 §4.2 says so and because losing one declaration of a broken document is better than losing all of them. resvg stops at the first one instead.

Where it differs from resvg

Its behaviour was checked against resvg one case at a time before it was written — every selector, every band of the cascade, CDATA, comments, the type attribute — and it agrees on all of them but two.

The general sibling combinator, ~, selects here and does not in resvg.

Pseudo-classes, @media and @import are refused here and silently skipped there, which is the difference that matters: resvg draws a picture missing rules the document meant, and this says it cannot draw one.

Where this lives

The repository lives in three places that carry the same history. The Forgejo instance at https://git.jcollie.dev/jeff/zig-css is the web-visible one:

$ git clone https://git.jcollie.dev/jeff/zig-css.git

it is mirrored on Tangled at https://tangled.org/jcollie.dev/zig-css, and it is also on the Radicle network, where the repository's identifier is

rad:z3obUmFsH4CdVdqcfrQpXyCjXeiSi

and rad clone rad:z3obUmFsH4CdVdqcfrQpXyCjXeiSi fetches it from any node that seeds it. Any of the three is the whole project.

Installation

$ zig fetch --save git+https://git.jcollie.dev/jeff/zig-css.git

and in build.zig:

const css = b.dependency("zig_css", .{ .target = target, .optimize = optimize });
mod.addImport("css", css.module("css"));

The module is named css and the package zig_css, so @import("css") is what the source says.

Building

$ nix develop
$ zig build test --summary all
$ zig build check          # compile everything, including what no test runs
$ zig build docs-serve     # read the API documentation at localhost:8000

Fuzzing

tests/fuzz.zig holds three targets — a stylesheet on its own, one declaration block, and a whole document with the cascade run against every element in it — and the properties they hold to: it comes back, a parse survives matching, and the cascade is a decision rather than a computation.

Zig 0.16.0 leaves the fuzzer's coverage table empty however the modules are built, so tools/fuzz.zig is a loop of our own: it mutates the corpus, hands the result to a target, and says what came back.

$ zig build fuzz-run -- --seconds 60
$ zig build fuzz-run -- --target stylesheet --alloc-fail

Each target is an ordinary test as well, so zig build test runs the corpus beside it and checks that every one of them survives an allocation failing anywhere inside it. That mode found a leak in the document target's own bookkeeping the first time it ran.

Dependencies

ztree, which is the XML document tree the selectors are matched against. Nothing else.

Matching needs a tree: a parent, the siblings in order, and the attributes of each element. A pull parser cannot answer g > rect at all, because by the time the <rect> arrives the <g> is gone.

Licence

MIT. See LICENSES/MIT.txt, and REUSE.toml for the files that cannot carry a header of their own.

References cited

Kept in the Zotero collection zig-css.

  • World Wide Web Consortium (W3C). (2011, June). Cascading Style Sheets Level 2 Revision 1 (CSS 2.1) Specification (W3C Recommendation). https://www.w3.org/TR/CSS21/ — §5 is the selector grammar, §6.4.3 the cascade, §4.1.9 what an unterminated comment does, and §4.2 why a malformed declaration is skipped rather than fatal.
  • World Wide Web Consortium (W3C). (2011, August). Scalable Vector Graphics (SVG) 1.1 (Second Edition) (W3C Recommendation). https://www.w3.org/TR/SVG11/ — §6 is the chapter this implements, and §6.4 is the sentence that puts the presentation attributes below everything else.
  • Reizner, Y. resvg. Linebender. https://github.com/linebender/resvg — the independent implementation this was checked against, one selector at a time.
  • Ollie, J. C. ztree. https://git.jcollie.dev/jeff/ztree — the XML document tree the selectors are matched against.