- Zig 96.5%
- Nix 3.5%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
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 |
||
| .forgejo/workflows | ||
| LICENSES | ||
| src | ||
| tests | ||
| tools | ||
| .gitignore | ||
| build.zig | ||
| build.zig.zon | ||
| flake.lock | ||
| flake.nix | ||
| README.md | ||
| REUSE.toml | ||
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:
!importantin astyleattribute!importantin a rule, by specificity- a
styleattribute - a rule, by specificity and then by source order
- 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.