on
Let's Build a Browser Engine!
제3 장: CSS
이 글은 Matt Brubeck의 Let’s Build a Browser Engine!을 번역한 글입니다.
알림 : 부족한 실력 탓에 잘못된 번역, 부자연스러운 문장이 있을 수 있습니다. 해당 문제에 대한 의견을 댓글이나 GitHub 저장소 Pull Request를 통해 제안해 주시면 감사한 마음으로 적극 반영하도록 하겠습니다. 감사합니다.
Let’s Build a Browser Engine!
제3 장: CSS
이 글은 토이 브라우저 렌더링 엔진 구축 시리즈의 세 번째 장입니다. 자신만의 엔진을 만들고 싶으신가요? 관심이 있다면 첫 장부터 시작해보세요.
이 글에서는 Cascading Style Sheets (CSS) 를 읽기 위한 코드를 소개합니다. 늘 그렇듯이 스펙의 모든 것을 다루지는 않습니다. 대신, 렌더링 파이프라인 안의 몇 가지 개념을 설명하고 이후 단계를 위한 입력을 만들 수 있을 정도로만 구현할 것입니다.
스타일시트 구조
여기 CSS 예제 소스 코드가 있습니다.
h1, h2, h3 { margin: auto; color: #cc0000; }
div.note { margin-bottom: 20px; padding: 10px; }
#answer { display: none; }
이제 필자의 토이 브라우저 엔진 Robinson의 css 모듈을 살펴볼 것입니다. 이 코드는 Rust로 작성되어 있지만 주요 개념들은 다른 프로그래밍 언어로도 쉽게 해석될 수 있을 것입니다. 이전 장을 먼저 읽으면 아래의 코드를 이해하는 데 도움이 될 것입니다.
CSS 스타일시트는 일련의 규칙(Rule)들입니다. (위의 예제 코드에서 각 줄은 하나의 규칙을 포함하고 있습니다.)
struct Stylesheet {
rules: Vec<Rule>,
}
하나의 규칙은 쉼표(콤마)로 구분된 하나 이상의 선택자(Selector), 이어서 중괄호로 싸여진 일련의 선언부(Declaration)를 포함합니다.
struct Rule {
selectors: Vec<Selector>,
declarations: Vec<Declaration>,
}
선택자는 단순 선택자또는 연결자(Combinator)로 결합된 체인 선택자가 될 수 있습니다. Robinson은 현재로선 단순 선택자만을 지원합니다.
주의: 혼란스럽게도 새로운 선택자 레벨3 표준은 의미가 약간 다른 것에 같은 용어를 사용합니다. 이 글에서는 대부분 CSS2.1을 언급합니다. 비록 구식이긴 하지만 규모가 작고 독립적이기 때문에 좋은 시작 지점이 됩니다. (상호 의존하는 무수한 스펙의 CSS3과 CSS2.1을 비교하는 경우)
Robinson의 경우 단순 선택자는 하나의 태그 이름, ‘#’으로 접두사 된 ID, ‘.’으로 접두사 된 임의의 클래스 이름 또는 위의 조합을 포함할수 있습니다. 태그 이름이 비어 있거나 ‘*’ 인 경우 어떤 태그와도 일치할 수 있는 “범용 선택자(Universal Selector)” 입니다.
다른 유형의 선택자도 많이 있지만 (특히 CSS3) 당분간은 이것만으로 충분합니다.
enum Selector {
Simple(SimpleSelector),
}
struct SimpleSelector {
tag_name: Option<String>,
id: Option<String>,
class: Vec<String>,
}
선언부는 쌍점(콜론)으로 구분되며 쌍반점(세미콜론)으로 끝나는 이름/값 쌍입니다. 예를 들어, margin: auto;
는 선언부 입니다.
struct Declaration {
name: String,
value: Value,
}
필자의 토이 엔진은 CSS의 많은 값 유형 중 일부만을 지원합니다.
enum Value {
Keyword(String),
Length(f32, Unit),
ColorValue(Color),
// 여기에 더 많은 값 유형을 추가하세요.
}
enum Unit {
Px,
// 여기에 더 많은 단위 유형을 추가하세요.
}
struct Color {
r: u8,
g: u8,
b: u8,
a: u8,
}
Rust 참고사항: u8은 8비트 unsigned integer이며 f32는 32비트 float입니다.
@-규칙, 주석, 위에서 언급하지 않은 모든 선택자/값/단위를 포함하는 모든 CSS 구문들은 지원하지 않습니다.
파싱
CSS는 정규 문법을 가지고 있어서 독특한 HTML보다 파싱 하는 것이 쉽습니다. 표준 CSS 파서는 파싱 에러를 발견하면 스타일시트의 인식되지 않은 부분을 버리지만 나머지 부분은 여전히 처리합니다.
이점은 스타일시트에 새로운 문법이 포함되는 것을 허용하면서 오래된 브라우저에서도 잘 정의된 출력을 생성하도록 하기 때문에 유용합니다.
Robinson은 2장의 HTML 파서와 같은 방식으로 만들어진 매우 단순한 (그리고 표준에 완전히 부합하지 않는) 파서를 사용합니다. 따라서 전체를 줄별로 다시 살펴보기 보다, 몇몇 토막들을 붙여 넣겠습니다. 다음 코드는 단순 선택자 파싱을 위한 코드입니다.
// 하나의 단순 선택자 파싱, 예: `type#id.class1.class2.class3`
fn parse_simple_selector(&mut self) -> SimpleSelector {
let mut selector = SimpleSelector { tag_name: None, id: None, class: Vec::new() };
while !self.eof() {
match self.next_char() {
'#' => {
self.consume_char();
selector.id = Some(self.parse_identifier());
}
'.' => {
self.consume_char();
selector.class.push(self.parse_identifier());
}
'*' => {
// 범용 선택자
self.consume_char();
}
c if valid_identifier_char(c) => {
selector.tag_name = Some(self.parse_identifier());
}
_ => break
}
}
return selector;
}
에러 검사가 빠져있음을 주의하길 바랍니다. ###
이나 \*foo\*
와 같이 몇몇 잘못된 입력은 파싱에 성공하여 이상한 결과를 만듭니다. 실제 CSS 파서는 이러한 잘못된 선택자를 버립니다.
명시도 (Specificity)
명시도는 렌더링 엔진이 스타일 충돌 시 어떤 스타일이 더 중요한지 결정하는 방식 중 하나입니다. 만약 스타일시트가 하나의 요소와 일치하는 두 가지 규칙을 포함하는 경우 더 높은 명시도의 선택자가 있는 규칙이 더 낮은 것을 무시하게 만들 수 있습니다.
선택자의 명시도는 그 구성요소에 기반합니다. ID 선택자는 클래스 선택자 보다 명시도가 높고, 클래스 선택자는 태그 선택자보다 높습니다. 각 “레벨” 내에서 더 많은 구성요소를 갖는 선택자가 낮은 쪽을 이깁니다.
pub type Specificity = (usize, usize, usize);
impl Selector {
pub fn specificity(&self) -> Specificity {
// http://www.w3.org/TR/selectors/#specificity
let Selector::Simple(ref simple) = *self;
let a = simple.id.iter().count();
let b = simple.class.len();
let c = simple.tag_name.iter().count();
(a, b, c)
}
}
(체인 선택자를 지원하려면 연결된 선택자들의 명시도를 단순히 더함으로써 체인 선택자의 명시도를 얻을 수 있을 것입니다.)
각 규칙의 선택자는 명시도가 높은 순서로 정렬된 벡터에 저장됩니다. 이것은 다음 장에서 다룰 매칭에서 중요해집니다.
// 하나의 규칙 집합을 파싱: `<selectors> { <declarations> }`.
fn parse_rule(&mut self) -> Rule {
Rule {
selectors: self.parse_selectors(),
declarations: self.parse_declarations()
}
}
// 쉼표(콤마)로 구분된 선택자 리스트를 파싱 합니다.
fn parse_selectors(&mut self) -> Vec<Selector> {
let mut selectors = Vec::new();
loop {
selectors.push(Selector::Simple(self.parse_simple_selector()));
self.consume_whitespace();
match self.next_char() {
',' => { self.consume_char(); self.consume_whitespace(); }
'{' => break, // 선언부의 시작
c => panic!("Unexpected character {} in selector list", c)
}
}
// 매칭에 사용하기 위해 명시도가 높은 순서로 선택자를 반환합니다.
selectors.sort_by(|a,b| b.specificity().cmp(&a.specificity()));
return selectors;
}
CSS 파서의 나머지는 매우 간단합니다. 독자는 GitHub에서 전체를 확인할 수 있습니다. 그리고 2장의 내용을 아직 수행해보지 않았다면 지금이 파서 생성기를 시험해볼 좋은 시간이 될 것입니다. 필자가 만든 파서는 간단한 예시 파일은 처리하지만 기존 가정을 지키지 않으면 형편없이 실패할 것입니다. 언젠가 필자는 rust-peg나 유사한 것으로 교체할 생각입니다.
연습
이전과 같이 독자가 연습을 진행할지 생략할지 결정하기 바랍니다.
-
독자만의 간단 CSS 파서와 명시도 계산을 구현해보세요.
-
Robinson의 CSS 파서를 확장하여 더 많은 값, 선택자 조합을 지원해보세요.
-
CSS 파서를 확장하여 파싱 에러를 포함하는 선언부를 버리고 에러 처리 규칙들을 따라 선언부 종료 후 파싱을 재개하도록 만들어보세요.
-
HTML 파서가 <style> 노드를 CSS 파서로 전달하도록 만들고 DOM 트리와 스타일 시트 목록이 포함된 문서 객체를 반환하도록 만들어보세요.
지름길
2장과 마찬가지로 독자는 CSS 구조를 하드 코딩하여 파싱을 생략할 수 있습니다. 또는 이미 파서가 있는 JSON과 같은 대체 형식으로 작성할 수도 있습니다.
다음 장에서 계속…
다음장에서는 style
모듈을 소개합니다. 모든 것이 합쳐지게 되며 CSS 스타일을 DOM 노드에 적용하기 위한 선택자 매칭이 시작됩니다.
필자가 이번 달 말에 바쁜 일정이 있고, 아직 다음 장을 위한 코드를 작성하지 않았기 때문에 이 시리즈의 진행 속도가 조금 늦춰질 수 있습니다. 최대한 빨리 돌아오도록 하겠습니다!
Discussion and feedback