🚀

超簡単なブラウザを作る(HTMLとレンダリングエンジン・入門編)

2025/01/05に公開

はじめに

HTMLを受け取ってレンダリングするだけの超簡単なブラウザを作りました。

リポジトリ
https://github.com/myuon/byo-browser

参考にした本
https://amzn.asia/d/jk6vPi1

こちらの本を年末年始に読んだことをきっかけに、同じような感じでHTMLのパーサーとレンダラーを書くのも面白いかなと思って始めました。
こちらの本では、C言語を使ってHTMLのパーサー、レンダリングを行うようなものです。こちらの本では /dev/fb0 へ直接書き込むことでGUIを再現していますが、今回はRustとwinitを使って作成することにしました。

winitとskiaによるレンダリング

winitとskiaというライブラリを使って画面の描画をしています。
winitが割とprimitiveなライブラリなので、それなりに低レベルなAPIを触る必要があります。

以下の記事が参考になりました。

https://blog1.mammb.com/entry/2024/03/12/000000

概ね、以下のようなコードを書けば良いです。

let (width, height) = {
    let size = window.inner_size();
    (size.width, size.height)
};
surface
    .resize(
        NonZeroU32::new(width).unwrap(),
        NonZeroU32::new(height).unwrap(),
    )
    .unwrap();

let mut raster_surface =
    skia_safe::surfaces::raster_n32_premul((width as i32, height as i32))
        .unwrap();
let canvas = raster_surface.canvas();
canvas.clear(0xFFFFFFFF);

// canvasに対する描画コードを書く

let pixdata = canvas.peek_pixels().unwrap();
let pixdata = pixdata.bytes().unwrap();

let mut buffer = surface.buffer_mut().unwrap();
for index in 0..(width * height) as usize {
    buffer[index] = pixdata[index * 4 + 2] as u32
        | (pixdata[index * 4 + 1] as u32) << 8
        | (pixdata[index * 4 + 0] as u32) << 16;
}
buffer.present().unwrap();

HTMLのパース

ブラウザを作るということは当然HTMLのパーサーを書くことになります。
例によってHTMLのパーサーは通常のパーサー同様に、tokenizeしてその後ASTを作るという方向で書きます。

HTMLが通常のプログラミング言語と少し違うところは、特に何もないところでテキストノードが出現するところかと思います。HTML要素の中でテキストノードは特に前触れなくいくらでも出現できるので、その辺りをパースするときに少し気をつける必要があります。

今回の私の実装では、tokenizeの時にsymbolあるいはスペースを見てトークンを分割しています。例えば <p>Hello World</p> みたいなものがあった時に、 [<, p, >, Hello, World, <, /, p, >] で分割します。
このようにした後に、p要素、Helloテキストノード、Worldテキストノードをパースして、これを描画します。スペースで分割しているのは大した意味はないですが、ブラウザのレンダラーの都合上単語ごとに描画したいことが多い気がしたのでそのようにしています。

walker

HTMLをASTに変換した後、レンダリング時にそれらを画面に描画していく必要があります。
多くのインタープリターなどでは AST -> Value なるevaluate関数を用意することが多いと思うのですが、レンダリングエンジンは値を返すことが目的ではなく副作用を伴う描画をしたいので、代わりにwalker(traverseを行う関数)を用意することにしました。

walkerは単にASTを順にtraverseし、見つけたnodeをcallbackに渡し続けるという単純なものです。そしてそこに、今見ているNodeをrootから辿った時のpath(例: html>body>div>span[2] みたいな感じ)を記録しておいてそれも併せてcallbackに渡すようにしました。
これによって、同じ要素であってもheadの中とbodyの中を明確に区別して描画処理を行うことができ、ブラウザのようなコンテキストが重要になるエンジンとしては大変便利に扱えます。このアイデアは、CSSにselectorなる仕組みが備わっていることと、eslintがこのASTに対するselectorをベースにルールをかける仕組みを提供していることなどから着想を得て実装しました。

hyperlinkの実装

上記のWebブラウザ実装本でもやっている通りハイパーリンクも実装しました。こちらはとても単純で、walk時にa要素に出会ったらそれを記録しておき、マウスクリックイベントにhookしてa要素のboundingBoxにヒットするか確認するだけです。

display:flexのサポート

最後に、現状のブラウザの実装ではほとんど機能らしい機能はありませんが、styleタグによる display:flex; gap:XXpx; のみサポートしたのでそちらの話をします。

現状の仕組みはかなり単純です。まずレンダリングエンジン上でstyle attributeのみ特別扱いし、発見したらCSSのパーサーにかけます。そしてflexboxの記述を発見したら、現状のノードの子要素に順に余白をつけていく、という実装を行なっています。
Rustのshared XOR mutableの都合上ノードに余白情報を載せられないので、例えば html>body>div がspanを子要素に複数持っており、divに display:flex; gap:16px の記述を発見したら、用意しておいたレイアウト用のmapに key: html>body>div>span[*]>gap-left (*のところには1-3までの値が入る) のようなレコードに16pxを入れていく、みたいな単純な実装をしています。

実装はかなり簡素ですが、これでgapが実現できるので結構良いです。

最後に

まだまだサポートしていないタグもあるし、CSSも最低限しかサポートしていないし、JSもサポートしていないしということでやり残しているところは無限にありますが、流石にキリがなさすぎるので一旦簡単に動くところまでにしました。

今後あえて実装を進めるなら何かしらゴールとなるところが欲しいところですが、個人的にやってみたいこととしては、

  • 画像のサポート
  • link要素などの外部リソース読み込みのサポート
  • JSの実装、あるいはJSじゃないブラウザ上で動作するランタイムエンジン
  • CSSでgridのサポート

などがあるかなと思っています。

ブラウザは巨大なソフトウェアなので実用的なものを作るには途方も無い労力が必要ですが、すごーーーく単純なものであれば割と簡単に作れるという実感が得られたのはよかったかなと思っています。

Discussion