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などがあるようです。
QualifiedRuleがdiv {display: none;}の様なよくみる対象にどの様なスタイルを当てるかを表しています。
これらをHTMLの時と同様にパースして処理がしやすい様にします。
また簡単のため、以下の制限が入っています。
- 0 個以上の style rule を持つ。
- 全ての style rule について、以下が成り立つ。
- comma-separated list of selectors の部分には combinator が含まれない。
- comma-separated list of selectors 内に含まれる simple selector は以下のうちどれかである。
- Universal Selector
- Class Selector
- Type Selector
- Attribute Selector
- declaration list の中には CSS Display Module Level 3 で定義されている display プロパティのみを持ち、このプロパティの値としては inline、none、block のどれかのみを指定できる。
やってる時はそこまで気にしてなかったんですが、 よくよく考えたらこれ結構ガッツリ絞ってるんでレンダリングまでいけたらここは少しできることを増やしたいです。 目標はこのサイトが表示できるとこまで。
実装
まずは前回同様にGitHubから演習用実装をダウンロードします。
宣言の実装
最初の実装は宣言(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()) のみだったため、2em や 16px のようなよくある値を認識できません。
例えば2 は数字なので letter() で弾かれます。
解決法としてCSSValue に Length を追加しました
// beforepub enum CSSValue { Keyword(String),}
// afterpub 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 を先に置くのがポイントです。
これらの変更で h1、h2 font-size: 2pxなど基本的なCSSが扱えるようになりました。次のステップとしてはカラー値 (#fff、rgb()) や複数値 (margin: 10px 20px) への対応が考えられます。
終わりに
今回はCSSをパースして、スタイルシートを作成するところまで行いました。 次回は前にやったHTMLの要素とスタイルシートからレンダリングツリーを作成します。 レンダリングまでいけたらやっとブラウザらしくなってくるので楽しみです。