Iori's Blog

Webブラウザ自作、4日目 CSSを取り扱う

Published on: Sun Apr 12 2026 00:00:00 GMT+0000 (Coordinated Universal Time)

前回の自作Webブラウザの記事からとんでもなく更新が遅れましたが、ぼちぼちやっていきます。

今回はCSSを取り扱えるようにしていきます。

参考にしているサイトはこちらです。

前提のCSSのデータ構造

CSSのデータ構造としては、参考にしているサイトの通り簡単に考えるなら2つに分けられます

#[derive(Debug, PartialEq)]
struct Stylesheet {
pub rules: Vec<Rule>,
}
enum Rule {
AtRule(AtRule),
QualifiedRule(QualifiedRule),
}
// 厳密には異なるが、一旦ここでは同一視する
type StyleRule = QualifiedRule;

これはAtRuleが@がついたルールを表しており、 例えば自分のサイトであればフォントを変更しているので

@font-face {
font-family: "HackGenConsoleNF";
src: url("/fonts/HackGenConsoleNF-Regular.ttf") format("truetype");
font-weight: normal;
font-style: normal;
font-display: swap;
}

のように使用しています。CSSそのものの振る舞いについてを規定する用途で用いられている様です。 他には@import@pageなどがあるようです。

QualifiedRulediv {display: none;}の様なよくみる対象にどの様なスタイルを当てるかを表しています。

これらをHTMLの時と同様にパースして処理がしやすい様にします。

また簡単のため、以下の制限が入っています。

やってる時はそこまで気にしてなかったんですが、 よくよく考えたらこれ結構ガッツリ絞ってるんでレンダリングまでいけたらここは少しできることを増やしたいです。 目標はこのサイトが表示できるとこまで。

実装

まずは前回同様にGitHubから演習用実装をダウンロードします。

tiny-browserbook/exercise-css
0

宣言の実装

最初の実装は宣言(declaration)部分の実装をします。 .test{ display: none; } のようなCSS文字列の中のdisplay: noneの部分をパースできる様にします。

まずサイト通りに実装を行いましたが、コンパイルができませんでした。

fn declarations<Input>() -> impl Parser<Input, Output = Vec<Declaration>>
where
Input: Stream<Token = char>,
Input::Error: ParseError<Input::Token, Input::Range, Input::Position>,
{
sep_end_by(
declaration().skip(whitespaces()),
char::char(';').skip(whitespaces()),
)
}
fn declaration<Input>() -> impl Parser<Input, Output = Declaration>
where
Input: Stream<Token = char>,
Input::Error: ParseError<Input::Token, Input::Range, Input::Position>,
{
(
many1(letter()).skip(whitespaces()),
char::char(':').skip(whitespaces()),
css_value(),
)
.map(|(k, _, v)| Declaration { name: k, value: v })
}
fn css_value<Input>() -> impl Parser<Input, Output = CSSValue>
where
Input: Stream<Token = char>,
Input::Error: ParseError<Input::Token, Input::Range, Input::Position>,
{
let keyword = many1(letter()).map(|s| CSSValue::Keyword(s));
keyword
}

原因としてwhitespacesが定義されていないことが挙げられるのでまずはこの部分を実装しました。 前後の文脈と関数名からwhitespacesは多分空白をとる関数なので以下のように実装しました。

fn whitespaces<Input>() -> impl Parser<Input, Output = String>
where
Input: Stream<Token = char>,
Input::Error: ParseError<Input::Token, Input::Range, Input::Position>,
{
many(space())
}

HTMLのパースを行った時と同様にmanyを使用することで空白を認識できる様にしています。

セレクタの処理

次は.test { display: none; }の様なCSS文字列の中の.testの部分を取り扱う処理を実装します。 ここはサイト通りに実装したので飛ばします。

ルールの処理

次にルールの処理を行います。 これもサイト通りに実装で違いないと思います。

fn rule<Input>() -> impl Parser<Input, Output = Rule>
where
Input: Stream<Token = char>,
Input::Error: ParseError<Input::Token, Input::Range, Input::Position>,
{
(
selectors().skip(whitespaces()),
char::char('{').skip(whitespaces()),
declarations().skip(whitespaces()),
char::char('}'),
)
.map(|(selectors, _, declarations, _)| Rule {
selectors,
declarations,
})
}

流れとしてはセレクタを取得→{を取得→宣言を取得→}を取得 という今まで通りのやつです。

スタイルシートへの格納

最後にこれらのルールをStylesheetという構造体に格納して終わりです。

pub fn parse(raw: String) -> Stylesheet {
rules()
.parse(raw.as_str())
.map(|(rules, _)| Stylesheet::new(rules))
.unwrap()
}
fn rules<Input>() -> impl Parser<Input, Output = Vec<Rule>>
where
Input: Stream<Token = char>,
Input::Error: ParseError<Input::Token, Input::Range, Input::Position>,
{
(whitespaces(), many(rule().skip(whitespaces()))).map(|(_, rules)| rules)
}

これの実装もmany(rule()...の前にwhitespaces()を入れ忘れたこと以外は実装できたので意外と分かってきた気がします。

拡張

この記事書いててやっぱほとんど認識できないのは面白くないと思ったので、 とりあえずよく使いそうなパースができる様に生成AIに聞きながらこれを拡張していきます。

まず聞いてみたところwhitespaces関数は今のままだとまずいらしく、

fn whitespaces<Input>() -> impl Parser<Input, Output = String>
where
Input: Stream<Token = char>,
Input::Error: ParseError<Input::Token, Input::Range, Input::Position>,
{
many(space())
}

many(space())では空白のみにしか対応できず、改行などに対応できないので少し改良して

many(satisfy(|c: char| c.is_whitespace()))

の様に変更しました。

次に数値・単位付きの値が認識できないといった問題があります。

css_value()many1(letter()) のみだったため、2em16px のようなよくある値を認識できません。 例えば2 は数字なので letter() で弾かれます。

解決法としてCSSValue に Length を追加しました

// before
pub enum CSSValue {
Keyword(String),
}
// after
pub enum CSSValue {
Keyword(String),
Length(f64, String), // (2.0, "em")
}

今まではKeywordのみを持っていましたが、ここに数字と単位を持てる様なLengthを追加することができるようにしました。

css_value() の拡張

fn css_value<Input>() -> impl Parser<Input, Output = CSSValue> {
let length = (
many1(satisfy(|c: char| c.is_numeric() || c == '.')),
many1(letter()),
)
.map(|(num, unit): (String, String)| {
CSSValue::Length(num.parse().unwrap(), unit)
});
let keyword = many1(letter()).map(CSSValue::Keyword);
choice((length, keyword))
}

数字で始まる場合は Length としてパースし、アルファベットで始まる場合は従来通り Keyword としてパースします。choice() は左から順に試みるので、length を先に置くのがポイントです。

これらの変更で h1h2 font-size: 2pxなど基本的なCSSが扱えるようになりました。次のステップとしてはカラー値 (#fffrgb()) や複数値 (margin: 10px 20px) への対応が考えられます。

終わりに

今回はCSSをパースして、スタイルシートを作成するところまで行いました。 次回は前にやったHTMLの要素とスタイルシートからレンダリングツリーを作成します。 レンダリングまでいけたらやっとブラウザらしくなってくるので楽しみです。

Tags:

programming

Rust