on
Let's Build a Browser Engine!
제6 장: 블록 레이아웃
이 글은 Matt Brubeck의 Let’s Build a Browser Engine!을 번역한 글입니다.
알림 : 부족한 실력 탓에 잘못된 번역, 부자연스러운 문장이 있을 수 있습니다. 해당 문제에 대한 의견을 댓글이나 GitHub 저장소 Pull Request를 통해 제안해 주시면 감사한 마음으로 적극 반영하도록 하겠습니다. 감사합니다.
Let’s Build a Browser Engine!
제6 장: 블록 레이아웃
토이 브라우저 엔진 구축 시리즈에 다시 오신 걸 환영합니다.
이번 장에서는 5장에서 시작한 레이아웃 모듈을 계속 진행합니다. 이번에는 블록 박스들을 배치하는 기능을 추가하겠습니다. 여기서의 블록들은 머리글과 단락과 같이 수직으로 쌓이는 블록들입니다.
단순함을 유지하기 위해 일반 대열(normal flow) 만을 구현합니다. 부동체(float), 절대 위치 잡기(absolute positioning) 그리고 고정 위치 잡기(fixed positioning)는 구현하지 않습니다.
레이아웃 트리 순회
이 코드의 진입점은 레이아웃 박스를 입력받아 박스의 면적을 계산하는 layout
함수입니다. 이 함수를 3개의 경우로 나눌 것이며, 지금은 그중 하나만을 구현할 것입니다.
impl LayoutBox {
// 박스와 박스의 자식 박스들을 배치합니다.
fn layout(&mut self, containing_block: Dimensions) {
match self.box_type {
BlockNode(_) => self.layout_block(containing_block),
InlineNode(_) => {} // TODO
AnonymousBlock => {} // TODO
}
}
// ...
}
블록의 레이아웃은 컨테이닝 블록(containing block)의 면적에 따라 달라집니다. 일반 대열에서 블록 박스의 컨테이닝 블록은 박스의 부모입니다. 루트 요소의 경우 브라우저 창 (또는 “뷰포트(viewport)”) 의 크기와 같습니다.
이전 장으로부터 블록의 너비는 부모에 따라 달라지는 반면 블록의 높이는 자식에 따라 달라진다는 것을 기억할 것입니다. 이것은 너비 계산 시 트리를 위에서 아래로 순회하여 부모의 너비를 얻은 후에 자식을 배치할 수 있도록 만들고, 높이 계산 시에는 먼저 자식들의 높이가 계산된 후 부모의 높이가 계산될 수 있도록 아래에서 위로 순회해야 함을 의미합니다.
fn layout_block(&mut self, containing_block: Dimensions) {
// 자식의 너비는 부모의 너비에 따라 달라집니다.
// 자식을 배치하기 전에 박스의 너비를 먼저 계산해야 합니다.
self.calculate_block_width(containing_block);
// 박스가 컨테이너 내에서 어디에 위치할지 결정합니다.
self.calculate_block_position(containing_block);
// 박스의 자식을 재귀적으로 배치합니다.
self.layout_block_children();
// 부모의 높이는 자식의 높이에 따라 달라집니다.
// `calculate_height`는 반드시 자식이 배치된 *이후* 호출되어야 합니다.
self.calculate_block_height();
}
이 함수는 트리를 내려가며 너비를 계산하고 이후 올라오면서 높이 계산을 수행하는 형식으로, 레이아웃 트리를 단일 순회하여 작업을 수행합니다. 실제 레이아웃 엔진은 여러 트리 순회 작업을 진행하며, 몇몇은 위에서 아래로 나머진 아래에서 위로 작업을 수행합니다.
너비 계산
너비 계산 작업은 블록 레이아웃 함수의 첫 번째 단계이며 가장 복잡한 단계입니다. 차근차근 살펴보겠습니다. 먼저 CSS width
속성과 좌우 가장자리 크기의 값이 필요합니다.
fn calculate_block_width(&mut self, containing_block: Dimensions) {
let style = self.get_style_node();
// `width` 는 초깃값으로 `auto`를 갖습니다..
let auto = Keyword("auto".to_string());
let mut width = style.value("width").unwrap_or(auto.clone());
// 마진, 테두리, 그리고 패딩은 초깃값으로 0을 갖습니다.
let zero = Length(0.0, Px);
let mut margin_left = style.lookup("margin-left", "margin", &zero);
let mut margin_right = style.lookup("margin-right", "margin", &zero);
let border_left = style.lookup("border-left-width", "border-width", &zero);
let border_right = style.lookup("border-right-width", "border-width", &zero);
let padding_left = style.lookup("padding-left", "padding", &zero);
let padding_right = style.lookup("padding-right", "padding", &zero);
// ...
}
여기서는 그저 일련의 값들을 순서대로 시도하는 lookup 헬퍼 함수를 사용하고 있습니다. 이 함수는 첫 번째 속성이 설정돼있지 않으면 두 번째 것을 시도합니다. 이 역시 설정돼있지 않은 경우 지정된 기본값을 반환합니다. 이렇게 함으로써 단축 속성(shorthand properties)과 초깃값을 불완전하지만 단순하게 구현할 수 있습니다.
참고 : JavaScript나 Ruby의 다음 코드와 유사합니다.
margin_left = style["margin-left"] || style["margin"] || zero;
자식은 부모의 너비를 변경할 수 없으므로 자신의 너비가 부모의 것에 맞는지 확인해야 합니다. CSS 명세에서는 이를 해결하기 위한 일련의 제약조건과 알고리즘을 제시합니다. 다음 코드는 이 알고리즘을 구현합니다.
먼저 마진, 패딩, 테두리 그리고 콘텐츠 너비를 더합니다. to_px 헬퍼 메소드는 길이를 정숫값으로 변환합니다. ‘auto’로 설정된 속성의 경우 0을 반환하므로 합계에 영향을 미치지 않습니다.
let total = [&margin_left, &margin_right, &border_left, &border_right,
&padding_left, &padding_right, &width].iter().map(|v| v.to_px()).sum();
이것은 박스에 필요한 최소 수평 공간입니다. 이 공간이 컨테이너의 너비와 같지 않다면 같게 만들기 위해 무언가를 조정해야 합니다.
width
또는 margin
이 ‘auto’로 설정된 경우 사용 가능한 공간에 맞게 확장하거나 축소시킬 수 있습니다. 먼저 명세에 따라 박스가 컨테이너 보다 더 큰지 확인합니다. 크다면 확장 가능한 margin
을 0으로 설정합니다.
// width가 auto가 아니고 total이 컨테이너 보다 넓은 경우 margin을 0으로 처리합니다.
if width != auto && total > containing_block.content.width {
if margin_left == auto {
margin_left = Length(0.0, Px);
}
if margin_right == auto {
margin_right = Length(0.0, Px);
}
}
박스가 컨테이너에 비해 매우 크면 컨테이너를 overflow가 됩니다. 반대로 너무 작으면 여분의 공간이 남으며 underflow가 됩니다. 우리는 underflow(컨테이너에 남은 공간의 값)를 계산합니다. (값이 음수이면 실제론 overflow입니다.)
let underflow = containing_block.content.width - total;
이제 확장 가능한 공간을 조정하여 overflow와 underflow를 제거하는 명세의 알고리즘을 구현합니다. ‘auto’로 설정된 공간이 없으면 오른쪽 마진을 조정합니다. (overflow의 경우 마진이 음수가 될 수 있음을 의미합니다!)
match (width == auto, margin_left == auto, margin_right == auto) {
// 값들이 매우 제한적인 경우(over-constrained), margin_right를 조정합니다.
(false, false, false) => {
margin_right = Length(margin_right.to_px() + underflow, Px);
}
// 하나의 크기만 auto로 설정된 경우
(false, false, true) => { margin_right = Length(underflow, Px); }
(false, true, false) => { margin_left = Length(underflow, Px); }
// width가 auto로 설정된 경우, 다른 모든 auto 값을 0으로 만듭니다.
(true, _, _) => {
if margin_left == auto { margin_left = Length(0.0, Px); }
if margin_right == auto { margin_right = Length(0.0, Px); }
if underflow >= 0.0 {
// underflow를 채우기 위해 width를 확장합니다.
width = Length(underflow, Px);
} else {
// Width는 음수가 될 수 없습니다. 오른쪽 마진을 대신 조정합니다.
width = Length(0.0, Px);
margin_right = Length(margin_right.to_px() + underflow, Px);
}
}
// margin-left와 margin-right 모두 auto인 경우, 둘의 사용 값은 균등하게 설정됩니다.
(false, true, true) => {
margin_left = Length(underflow / 2.0, Px);
margin_right = Length(underflow / 2.0, Px);
}
}
이때 조건이 충족되는 모든 ‘auto’ 값들은 길이로 변환됩니다. 결과는 레이아웃 트리에 저장할 수평 박스 면적의 사용 값입니다. 최종 코드는 layout.rs에서 확인할 수 있습니다.
위치 잡기 (Positioning)
다음 단계는 단순합니다. 이 함수는 남은 마진/패딩/테두리 스타일을 찾고 컨테이닝 블록의 크기와 함께 페이지 내 블록 위치를 결정합니다.
fn calculate_block_position(&mut self, containing_block: Dimensions) {
let style = self.get_style_node();
let d = &mut self.dimensions;
// 마진, 테두리, 패딩은 초깃값 0을 갖습니다.
let zero = Length(0.0, Px);
// margin-top 이나 margin-bottom 이 `auto` 이면, 사용값은 0입니다.
d.margin.top = style.lookup("margin-top", "margin", &zero).to_px();
d.margin.bottom = style.lookup("margin-bottom", "margin", &zero).to_px();
d.border.top = style.lookup("border-top-width", "border-width", &zero).to_px();
d.border.bottom = style.lookup("border-bottom-width", "border-width", &zero).to_px();
d.padding.top = style.lookup("padding-top", "padding", &zero).to_px();
d.padding.bottom = style.lookup("padding-bottom", "padding", &zero).to_px();
d.content.x = containing_block.content.x +
d.margin.left + d.border.left + d.padding.left;
// 컨테이너 안에서 이전에 위치한 모든 박스들 밑에 박스를 위치시킵니다.
d.content.y = containing_block.content.height + containing_block.content.y +
d.margin.top + d.border.top + d.padding.top;
}
y 위치를 설정하는 마지막 문장을 주의 깊게 보기 바랍니다. 이 문장이 블록 레이아웃에 고유한 수직 쌓기 동작을 제공합니다. 이렇게 하려면 각각의 자식들을 배치한 후 부모의 content.height를 갱신해야 합니다.
자식 (Children)
여기 박스의 콘텐츠를 재귀적으로 배열하는 코드가 있습니다. 자식 박스를 반복 순회해서 전체 콘텐츠 높이를 추적합니다. 위치 잡기 코드(바로 위의 코드)가 다음 자식의 수직 위치를 찾을 때 사용합니다.
fn layout_block_children(&mut self) {
let d = &mut self.dimensions;
for child in &mut self.children {
child.layout(*d);
// 높이 추적을 통해 자식 박스들이 이전 콘텐츠 아래에 배치되도록 합니다.
d.content.height = d.content.height + child.dimensions.margin_box().height;
}
}
각각의 자식이 차지하는 총 수직 공간은 마진 박스의 높이이며, 다음과 같이 계산합니다.
impl Dimensions {
// 콘텐츠 영역에 패딩을 더한 영역입니다.
fn padding_box(self) -> Rect {
self.content.expanded_by(self.padding)
}
// 콘텐츠 영역에 패딩과 테두리를 더한 영역입니다.
fn border_box(self) -> Rect {
self.padding_box().expanded_by(self.border)
}
// 콘텐츠 영역에 패딩, 테두리, 마진을 더한 영역입니다.
fn margin_box(self) -> Rect {
self.border_box().expanded_by(self.margin)
}
}
impl Rect {
fn expanded_by(self, edge: EdgeSizes) -> Rect {
Rect {
x: self.x - edge.left,
y: self.y - edge.top,
width: self.width + edge.left + edge.right,
height: self.height + edge.top + edge.bottom,
}
}
}
단순함을 위해 마진 상쇄(margin collapsing)는 구현하지 않습니다. 실제 레이아웃 엔진은 각 마진 박스를 이전 박스의 완전한 아래에 배치하는 대신 한 박스의 아래쪽 마진이 다음 박스의 위쪽 마진과 겹칠 수 있도록 합니다.
‘height’ 속성
기본적으로 박스의 높이는 콘텐츠의 높이와 같습니다. 그러나 ‘height’ 속성에 길이가 명시된 경우 해당 값을 대신 사용합니다.
fn calculate_block_height(&mut self) {
// height에 길이가 명시된 경우 해당 길이를 사용합니다.
// 그렇지 않으면 `layout_block_children`을 통해 설정된 값을 계속 사용합니다.
if let Some(Length(h, Px)) = self.get_style_node().value("height") {
self.dimensions.content.height = h;
}
}
이제 블록 배치 알고리즘이 끝이 났습니다. 이제 스타일 처리된 HTML 문서에서 layout()
을 호출하면 너비, 높이, 마진 등을 가진 수많은 직사각형들을 얻을 수 있게 되었습니다. 멋지지 않나요?
연습
열정 넘치는 독자들을 위한 몇 가지 추가 아이디어입니다.
-
수직 마진 상쇄.
-
상대 위치 잡기.
-
레이아웃 과정을 병렬화하고 성능에 미치는 영향 측정하기.
독자가 병렬 프로젝트를 시도하는 경우 너비 계산과 높이 계산을 별도로 분리할 수 있습니다. 너비에 대한 하향식 순회는 각 자식에 대해 별도의 작업을 생성하는 것만으로 쉽게 병렬화할 수 있습니다. 높이 계산의 경우 형제를 먼저 배치하고 y 위치를 조정해야 하기 때문에 조금 까다로울 것입니다.
다음 장에서 계속…
여기까지 잘 따라와 주신 모든 독자분들께 감사드립니다!
익숙지 않은 레이아웃과 렌더링 영역으로 여정을 진행하면서 글을 쓰는데 점점 더 오래 걸리고 있습니다. 다음 장에서는 글꼴과 그래픽 코드를 실험하게 되면서 공백이 더 길어질 것 같습니다. 하지만 가능한 한 빨리 시리즈를 재개하겠습니다.
업데이트: 7장이 준비되었습니다.
Discussion and feedback