Let's Build a Browser Engine!
제5 장: 박스

이 글은 Matt BrubeckLet’s Build a Browser Engine!을 번역한 글입니다.

알림 : 부족한 실력 탓에 잘못된 번역, 부자연스러운 문장이 있을 수 있습니다. 해당 문제에 대한 의견을 댓글이나 GitHub 저장소 Pull Request를 통해 제안해 주시면 감사한 마음으로 적극 반영하도록 하겠습니다. 감사합니다.

Let’s Build a Browser Engine!

제5 장: 박스

간단 HTML 렌더링 엔진 제작 시리즈의 최신 글입니다.

이번 장에서는 스타일 트리를 입력받아 2차원 공간에 여러 개의 직사각형으로 변환하는 레이아웃 모듈을 다룹니다. 이 모듈은 매우 크기 때문에 여러 장에 걸쳐 진행하도록 하겠습니다. 추가로 이 글에서 공유하는 코드 중 일부가 이후 장의 코드를 작성함에 따라 변경될 수도 있습니다.

레이아웃 모듈의 입력은 4장의 스타일 트리이며, 출력은 또 다른 트리인 레이아웃 트리입니다. 이 단계를 통해 미니 렌더링 파이프라인에서 한 단계 더 나아갈 수 있게 됩니다.

토이 브라우저 엔진 파이프라인
토이 브라우저 엔진 파이프라인

먼저 기본 HTML/CSS 배치 모델에 대해 얘기해보려 합니다. 독자가 웹페이지 개발을 배운 적이 있다면 이미 익숙할 수 있습니다. 하지만 이는 구현자의 관점에선 조금 다릅니다.

박스 모델

레이아웃은 모두 박스에 관한 것입니다. 박스는 웹 페이지의 직사각형 형태의 구역을 의미합니다. 박스는 폭, 높이 그리고 페이지에서 위치를 갖습니다. 이 직사각형은 박스의 콘텐츠가 그려지는 곳이기 때문에 콘텐츠 영역이라고 합니다. 콘텐츠는 텍스트, 이미지, 비디오 또는 다른 박스가 됩니다.

또한 박스는 콘텐츠 영역을 둘러싸는 패딩(padding), 테두리(border), 마진(margin)을 갖습니다. CSS 명세에는 이러한 모든 계층이 서로 어떻게 부합하는지 보이는 도표가 있습니다.

Robinson은 박스의 콘텐츠 영역과 주변 영역들을 다음과 같은 구조에 저장합니다. [Rust 참고사항: f32는 32비트 부동소수점 형식입니다.]

// CSS 박스 모델. 모든 사이즈는 px입니다.
struct Dimensions {
    // 문서 기준으로 한 콘텐츠 영역의 위치
    content: Rect,

    // 주변 모서리(edge)
    padding: EdgeSizes,
    border: EdgeSizes,
    margin: EdgeSizes,
}

struct Rect {
    x: f32,
    y: f32,
    width: f32,
    height: f32,
}

struct EdgeSizes {
    left: f32,
    right: f32,
    top: f32,
    bottom: f32,
}

블록과 인라인 레이아웃

CSS display 속성은 요소가 생성하는 박스 유형을 결정합니다. CSS는 몇 가지 박스 유형을 정의하며 각 유형은 고유한 레이아웃 규칙을 갖습니다. 필자는 그중 블록인라인 유형에 대해서만 이야기하려고 합니다.

아래 유사 HTML 코드를 사용하여 차이점을 설명하겠습니다.

<container>
  <a></a>
  <b></b>
  <c></c>
  <d></d>
</container>

블록 박스는 컨테이너의 위에서 아래로 수직으로 배치됩니다.

a, b, c, d { display: block; }
a
b
c
d

인라인 박스는 컨테이너의 좌에서 우로 수평으로 배치됩니다. 컨테이너의 우측 가장자리(edge)에 도달하는 경우 휘감겨서 아래 새로운 줄로 계속 이어집니다.

a
b
c
d

각 박스는 블록 박스 만 포함하거나 인라인 박스 만 포함할 수 있습니다. DOM 요소가 블록과 인라인이 혼합된 자식 박스들을 갖는 경우 레이아웃 엔진은 익명 박스(Anonymous box)를 삽입하여 두 유형을 분리합니다. (이러한 박스는 DOM 트리의 노드와 연결되어 있지 않기 때문에 “익명”이라고 합니다.)

아래 예에서 인라인 박스 b와 c는 분홍색으로 표시된 익명 블록 박스 에 포함됩니다.

a    { display: block; }
b, c { display: inline; }
d    { display: block; }
a
b
c
d

콘텐츠는 기본적으로 수직으로 증가하는 것을 주의하시기 바랍니다. 즉, 컨테이너에 자식 박스를 추가하면 일반적으로 컨테이너를 더 넓게 가 아닌 더 높게 만듭니다. 달리 말하자면 기본적으로 블록이나 줄의 너비는 컨테이너의 너비에 달려있고 컨테이너의 높이는 자식 박스들의 높이에 달려있습니다.

이것은 너비나 높이 속성들의 기본값을 재정의하는 경우 또는 수직 쓰기와 같은 기능을 지원하는 경우 더 복잡해집니다.

레이아웃 트리

레이아웃 트리는 박스의 집합입니다. 박스는 크기를 가지며 자식 박스를 포함할 수도 있습니다.

struct LayoutBox<'a> {
    dimensions: Dimensions,
    box_type: BoxType<'a>,
    children: Vec<LayoutBox<'a>>,
}

박스는 블록 노드, 인라인 노드 또는 익명 블록 박스가 될 수 있습니다. (텍스트 레이아웃 구현 시 라인 랩핑으로 단일 인라인 노드가 여러 박스로 분할될 수 있기 때문에 변경이 필요합니다. 하지만 지금은 괜찮습니다.)

enum BoxType<'a> {
    BlockNode(&'a StyledNode<'a>),
    InlineNode(&'a StyledNode<'a>),
    AnonymousBlock,
}

레이아웃 트리를 만들기 위해 DOM 노드 각각의 display 속성을 확인해야 합니다. 노드의 display 값을 얻기 위해 style 모듈에 몇 가지 코드를 추가하였습니다. 명시된 값이 없는 경우 초깃값인 ‘인라인’을 반환합니다.

enum Display {
    Inline,
    Block,
    None,
}

impl StyledNode {
    // 속성의 명시된 값이 존재하는 경우 반환, 그렇지 않으면 `None`
    fn value(&self, name: &str) -> Option<Value> {
        self.specified_values.get(name).map(|v| v.clone())
    }

    // `display` 속성의 값 (기본값은 인라인)
    fn display(&self) -> Display {
        match self.value("display") {
            Some(Keyword(s)) => match &*s {
                "block" => Display::Block,
                "none" => Display::None,
                _ => Display::Inline
            },
            _ => Display::Inline
        }
    }
}

이제 스타일 트리를 탐색하여 각 노드에 대해 레이아웃 박스를 만들고 각 노드의 자식을 위한 박스를 삽입할 수 있습니다. 노드의 display 속성이 ‘none’ 설정된 경우 해당 노드는 레이아웃 트리에 포함되지 않습니다.

// 레이아웃 박스의 트리를 생성합니다. 아직 레이아웃 연산을 수행하지는 않습니다.
fn build_layout_tree<'a>(style_node: &'a StyledNode<'a>) -> LayoutBox<'a> {
    // 루트 박스 생성
    let mut root = LayoutBox::new(match style_node.display() {
        Block => BlockNode(style_node),
        Inline => InlineNode(style_node),
        DisplayNone => panic!("Root node has display: none.")
    });

    // 하위 박스 생성
    for child in &style_node.children {
        match child.display() {
            Block => root.children.push(build_layout_tree(child)),
            Inline => root.get_inline_container().children.push(build_layout_tree(child)),
            DisplayNone => {} // `display: none;` 인 경우 생략
        }
    }
    return root;
}

impl LayoutBox {
    // 생성자 함수
    fn new(box_type: BoxType) -> LayoutBox {
        LayoutBox {
            box_type: box_type,
            dimensions: Default::default(), // 모든 필드를 0.0으로 초기화
            children: Vec::new(),
        }
    }
    // ...
}

블록 노드에 인라인 자식이 포함된 경우 이를 포함할 익명 블록 박스를 만듭니다. 여러 인라인 자식이 있는 경우 모두 같은 익명 컨테이너에 넣습니다.

// 새로운 인라인 자식 노드가 가게 되는 곳
fn get_inline_container(&mut self) -> &mut LayoutBox {
    match self.box_type {
        InlineNode(_) | AnonymousBlock => self,
        BlockNode(_) => {
            // 익명 블록 박스를 하나 만들었다면 계속 사용.			
            // 그렇지 않으면, 새 박스를 하나 생성
            match self.children.last() {
                Some(&LayoutBox { box_type: AnonymousBlock,..}) => {}
                _ => self.children.push(LayoutBox::new(AnonymousBlock))
            }
            self.children.last_mut().unwrap()
        }
    }
}

이는 표준 CSS 박스 생성 알고리즘의 여러 가지 방법들을 의도적으로 단순화 한 것입니다. 예를 들어 인라인 박스에 블록 레벨 자식이 포함된 경우는 처리하지 않습니다. 게다가 블록 레벨 노드가 인라인 자식만 갖는 경우 불필요한 익명 박스를 생성하기도 합니다.

다음 장에서 계속…

생각보다 오래 걸렸습니다. 필자는 여기서 잠깐 쉬어가려고 합니다. 그러나 걱정하지 마세요. 6장이 곧 나올 것이며 블록 레벨 레이아웃을 다룰 것입니다.

블록 레이아웃이 완료되면 마침내 파이프라인의 다음 단계인 페인팅으로 이동할 수 있습니다. 그러면 마침내 숫자가 아닌 예쁜 그림의 출력 결과를 볼 수 있을 것입니다.

그러나 인라인 레이아웃과 텍스트 레이아웃을 구현하여 레이아웃 모듈을 완료하지 않는 한 그림은 단지 여러 색상의 직사각형일 뿐입니다. 필자는 페인팅 단계로 넘어가기 전에 그것들을 구현하지 못한다면 나중에라도 다룰 수 있도록 하겠습니다.

Discussion and feedback