브라우저 스터디 기록 (3)

Jaeyeol Lee @kodingwarrior@hackers.pub

Note

이 글은 Web Browser Engineering 을 독학하면서 시도했던 것들을 의식의 흐름대로 남긴 흔적입니다.

TL;DR - Chapter 3 연습문제 풀이를 보고 싶다면 여기서 확인할 수 있다.

Chapter 3는 좀 빡세다는 느낌이 들었다. 이 글을 작성하는 시점에는 Chapter 4까지 이미 끝내놓은 상태이긴 하지만, 런타임 환경마다 각자 다르게 동작하는 폰트 렌더링이라던가 후술할 일부 연습문제가 굉장히 골치가 아팠던 것으로 기억한다. 그만큼 텍스트 레이아웃이라는 심연히 굉장히 골때리다는 것이고, "폰트렌더링과 씨름했던 사람들은 어떤 싸움을 해온 것인가..." 라는 생각이 들곤 했다.

Chapter 2에서는 글자를 하나씩 하나씩 고정된 간격으로 렌더링했었다. 고정폭 글자 기준으로는 이렇게 해도 문제가 없긴 하지만, 가변폭 글자 기준으로는 가독성이 굉장히 떨어진다. 예를 들자면, a 이라는 글자의 폭이 다르고, l 이라는 글자의 폭이 다르다. 그리고, 단어를 구성하는 각 글자의 폭을 합친 값과 단어 자체의 폭도 값이 다르다. 그래서, 텍스트 자체를 렌더링할때는 단어 단위로 렌더링하는 편이 좀 더 정밀하다고 볼 수 있다.

그리고, 여러가지 폰트가 섞여있는 상황에서 글자가 올바르게 배치되도록 하기 위해서 baseline, ascent, descent라는 개념이 있다.

이 세 가지는 말하자면 글자가 어디에 ‘앉는지’와 ‘얼마나 위아래로 뻗는지’를 결정하는 기준선들이다.

  • baseline은 모든 글자가 공통으로 맞춰야 하는 “바닥선”이다. 대부분의 글자는 이 선 위에 앉아 있다.
  • ascent는 글자가 위로 얼마나 뻗는지를 나타내는 값이다. 예를 들어 “h”나 “b” 같은 글자는 베이스라인 위로 높게 올라가므로 ascent 값이 크다.
  • descent는 반대로 글자가 아래로 얼마나 내려가는지를 나타낸다. “p”나 “g”, “y”처럼 꼬리가 밑으로 내려가는 글자들이 descent를 가진다.

이걸 눈으로 보면 아주 단순해 보이지만, 렌더러 입장에서는 꽤 골치 아픈 개념이다. 왜냐면, 폰트마다 ascent와 descent의 비율이 다르고, 심지어 같은 폰트라도 굵기(weight)나 스타일(italic)에 따라 기준선이 조금씩 달라지기 때문이다. 그래서 브라우저는 단순히 “텍스트를 그린다”기보다는, 각 글자가 가진 메트릭 값을 모두 고려해서 줄 전체가 시각적으로 균형 잡히도록 맞춰야 한다.

우리가 평소에 아무렇지 않게 읽던 한 줄의 텍스트가 사실 꽤 정교한 계산 위에서 표시된다는 걸 알 수 있다. 그냥 “글자가 줄 맞춰진다”는 게 아니라, 각 문자의 메트릭 값이 조합되어 시각적으로 균형을 이루도록 배치되는 것이다. 이렇게 각 글자의 형태와 위치를 조정해 실제 표시될 글자(glyph)로 변환하는 일련의 과정을 shaping이라고 한다.

Chapter 3에서는 단어 단위로 렌더링하기 전에 Shaping 하는 과정을 소개한다.

폰트 렌더링이라는 심연

이 교재를 Linux/macOS 각각 다른 기기를 번갈아가면서 실습하는 사람은 이미 실감했을 것인데, font 글자의 가로폭을 계산하는 과정이 굉장히 느리다. 사실 이것은 tkinter 자체의 구현이 문제인데, tkinter에서 font.measure 함수를 호출할때 런타임마다 동작하는 방식이 다르다. macOS 구현체는 CoreText(소스코드는 비공개)라는 라이브러리에 내장된 측정 함수를 그대로 가져다 쓰기 때문에, 측정이 거의 네이티브라고 볼 수 있을 정도로 굉장히 빠르다. 하지만, Linux는....? 폰트 정보를 가져오고 측정함수를 호출하기 위해 X 서버를 거쳐야 하기 때문에, 성능이 몇배는 차이가 난다. 여기서 큰 차이를 만들어낸다. 실습이라는게 되고 안되고가 나뉠 정도로.

Linux 환경에서는 화면을 매번 렌더링할 때마다 너무 느려서 실습을 포기할 정도가 될 수 밖에 없는데, 이건 정상적인 실습환경이라고 볼 수 없다. 그럼에도 불구하고, 방법은 있다. 바로, 브라우저에서 처음 렌더링하는 시점에, (폰트 패밀리, 폰트 크기, 폰트 스타일) 을 키로 활용하여서 각각에 매칭되는 가변폭 글자 각각의 길이를 미리 측정해서 어딘가에다가 캐싱해두는 것이다. 렌더링 루프에서 실시간으로 font.measure()를 호출하기 전에 미리 캐싱해두는 것이다. 그리고 단어의 길이를 측정할때는 단어를 구성하는 각 문자의 가로폭을 합치는 식으로 계산하면 된다. 예시 코드는 아래와 같다.

from dataclasses import dataclass, field
import tkinter.font


@dataclass
class FontMeasurer:
    cache: dict[tuple[float, str, str, str], dict[str, float]] = field(default_factory=dict)
    fixed_cjk_width: dict[tuple[float, str, str, str], float] = field(default_factory=dict)

    def _font_key(self, font: tkinter.font.Font):
        return (
            font.cget("size"),
            font.cget("weight"),
            font.cget("slant"),
            font.cget("family"),
        )

    def _is_cjk(self, ch: str) -> bool:
        code = ord(ch)
        return (
            0x4E00 <= code <= 0x9FFF or  # Kanji
            0xAC00 <= code <= 0xD7A3 or  # Hangul
            0x3040 <= code <= 0x30FF or  # hiragana, katakana
            0x31F0 <= code <= 0x31FF or  # katakana extension
            0x3400 <= code <= 0x4DBF or  # CJK extension A
            0xFF00 <= code <= 0xFF60     # Fullwidth roman characters and halfwidth katakana
        )

    def _prefetch_ascii_widths(self, font: tkinter.font.Font, cache: dict[str, float]):
        """Prefetch widths for common ASCII characters."""
        ascii_chars = (
            [chr(i) for i in range(32, 127)]  # printable ASCII
        )
        for ch in ascii_chars:
            if ch not in cache:
                cache[ch] = font.measure(ch)

    def measure(self, font: tkinter.font.Font, text: str) -> float:
        if not text:
            return 0.0

        key = self._font_key(font)
        cache = self.cache.setdefault(key, {})

        # Prefetch widths for common ASCII characters (only once)
        if " " not in cache:
            self._prefetch_ascii_widths(font, cache)

        # Initialize fixed CJK width if not already done
        if key not in self.fixed_cjk_width:
            self.fixed_cjk_width[key] = font.measure("")

        result = cache.get(text)
        if result is not None:
            return result

        # Single character case
        if len(text) == 1:
            if text not in cache:
                cache[text] = (
                    self.fixed_cjk_width[key]
                    if self._is_cjk(text)
                    else font.measure(text)
                )
            return cache[text]

        # Multi-character case
        width = 0.0
        for ch in text:
            w = cache.get(ch)
            if w is None:
                w = (
                    self.fixed_cjk_width[key]
                    if self._is_cjk(ch)
                    else font.measure(ch)
                )
                cache[ch] = w
            width += w

        cache[text] = width
        return width

font_measurer = FontMeasurer()

이는 인메모리에 접근해서 계산하는 것이기 때문에 X 서버를 거치는 것보다 굉장히 빠르다. 그리고, 일부 단어는 빈번하게 등장할 수 있기 때문에 렌더링하는 성능은 더 올라갈 수 밖에 없다.

물론 이것은 근본적인 해결책은 아닐 수 있다. 아까 언급했듯이, 가변폭 글자 각각의 가로폭을 합치는 것과 단어 자체의 엄밀한 가로폭은 다르다. 하지만, 브라우저가 동작하는걸 눈으로 확인하기도 어려운 상황에서 자연스러운 속도로 렌더링되게 한다면 이는 감당이 가능한 비용이다. 이 프로젝트의 목표가 “브라우저의 동작을 눈으로 직접 확인해보는 것”이라는 점을 고려하면, 속도와 정밀도 사이의 이 정도 타협은 충분히 감당 가능한 수준이다.

참고로, macOS는 위와 같이 캐싱을 굳이 하지 않아도 빠르다(.....)

실제 브라우저들은 이 문제를 훨씬 더 정교하게 다룬다. 예를 들어 크로미움(Chromium)은 HarfBuzz 라는 오픈소스 라이브러리를 내장해, 플랫폼에 상관없이 동일한 shaping과 측정 알고리즘을 사용한다. 덕분에 Linux에서도 macOS와 거의 같은 속도로 텍스트를 렌더링할 수 있다

연습문제 풀이

3.1(중앙정렬)의 경우, 2.5(ltr 지원)에서 가로 넓이를 계산했었다면 어렵지 않게 풀 수 있다. 3.3(<abbr> 태그 지원)는 그냥 문제의 요구사항대로 upper case로 만들어주는 것만 신경써주면 된다. 3.4(soft-hyphen 지원) 의 경우, current_width + wscreen_width를 넘어서는 시점에 어느 부분부터 자를지 계산만 잘해주고 다음 행으로 개행시키면 된다.

연습문제 3.2 : <sup>, <sub> 태그 지원

sup 태그와 sub 태그가 사용되는 모습

<sup><sub>는 단순히 글자 크기를 조정하는 태그가 아니라, 줄의 기준선(baseline) 자체를 움직이는 태그다. 같은 줄 안에서도 글자가 서로 다른 높이에 놓일 수 있기 때문에, 단순히 y좌표를 더하거나 빼는 식으로 처리하면 줄 전체가 어긋나 버린다. 이번 구현에서는 이러한 문제를 해결하기 위해 기준선의 변화를 스택(stack) 으로 관리했다. BufferLinecontext_stack<sup><sub>가 열릴 때마다 새로운 기준선 정보를 push하고, 닫힐 때 pop하여 복원하는 방식으로 작동한다. <sup>은 현재 폰트의 ascent를 기준으로 약 1/4만큼 위로, <sub>descent를 기준으로 약 1/4만큼 아래로 이동하며, 이렇게 쌓이는 컨텍스트 덕분에 첨자가 중첩되더라도 각 글자의 상대적인 높이를 정확히 계산할 수 있다.

이 구조의 장점은 첨자 내부에서도 폰트 관련 태그를 자유롭게 섞어 쓸 수 있다는 점이다. 예를 들어 <sup> 안에서 <big>, <small>, <b>(또는 <strong>), <i> 같은 태그가 들어와도 기준선이 흔들리지 않는다. 폰트 크기나 굵기, 스타일 변경은 VerticalAlignContext 안에서만 영향을 주기 때문에, 텍스트의 세로 위치는 여전히 안정적으로 유지된다. 실제로 <sup><big>Text</big></sup>처럼 크기를 키우거나 줄이더라도, 글자는 원래의 기준선 위에서 자연스럽게 정렬된다. 이 덕분에 폰트 스타일과 세로 정렬이 서로 간섭하지 않으면서도, 브라우저와 비슷한 안정적인 렌더링 결과를 얻을 수 있다.

줄이 끝날 때(flush())는 스택에 쌓인 글자들의 상대적인 y좌표를 바탕으로 줄 전체의 높이를 계산한다. 흥미로운 점은, 기준선 컨텍스트가 줄 경계에서 바로 사라지지 않는다는 것이다. <sup>가 한 줄에서 열리고 다음 줄에서 닫히는 경우에도 이전의 기준선 상태가 그대로 유지되어, 여러 줄에 걸친 첨자 구조가 자연스럽게 이어진다. BufferLine은 스택이 완전히 비었을 때만 기준선을 0으로 복원하기 때문에, 각 줄의 높이는 독립적으로 계산되면서도 전체 문맥은 유지된다. 이런 구조 덕분에 깊은 중첩이나 복잡한 폰트 조합이 등장하더라도, 텍스트 레이아웃은 줄과 줄 사이에서 끊기지 않고 매끄럽게 이어진다.

예시 코드는 아래와 같다.

class BufferLine:
    words: list[tuple[float, float, str, tkinter.font.Font]] = field(default_factory=list)
    baseline: float = 0.0
    current_baseline: float = 0.0

    context_stack: list[VerticalAlignContext] = field(default_factory=list)

    def clear(self):
        self.words.clear()

    def is_empty(self) -> bool:
        return len(self.words) == 0

    @property
    def previous_baseline(self) -> float:
        if not self.context_stack:
            return 0
        return self.context_stack[-1].relative_baseline_y

    def add_word(self, *, x: float, font: tkinter.font.Font, word: str):
        ...

    def calculate_bounds(self) -> tuple[float, float]:
        ...

    def add_context(self, context: VerticalAlignContext):
        self.context_stack.append(context)
        self.current_baseline = context.relative_baseline_y

    def pop_context(self) -> VerticalAlignContext:
        if not self.context_stack:
            raise RuntimeError("No context to pop")

        context = self.context_stack.pop()
        if self.context_stack:
            self.current_baseline = self.context_stack[-1].relative_baseline_y
        else:
            self.current_baseline = 0.0

        return context
  	

class Layout:
  	def handle_tag(tag: str):
      	...
		elif tag == 'sup':
            current_font = self.get_font(self.size, self.font_weight, self.style)
            metrics = current_font.metrics()
            ascent = metrics["ascent"]
            baseline_y = self.buffer_line.previous_baseline - int(ascent * 0.25)
            self.buffer_line.add_context(
                VerticalAlignContext(
                    restore_size=self.size,
                    relative_baseline_y=baseline_y,
                    weight=self.font_weight,
                    style=self.style
                )
            )
            previous_size = self.size
            self.size = int(previous_size * 0.75)
        elif tag == "sub": 
            current_font = self.get_font(self.size, self.font_weight, self.style)
            metrics = current_font.metrics()
            descent = metrics["descent"]
            baseline_y = self.buffer_line.previous_baseline + int(descent * 0.25)
            self.buffer_line.add_context(
                VerticalAlignContext(
                    restore_size=self.size,
                    relative_baseline_y=baseline_y,
                    weight=self.font_weight,
                    style=self.style
                )
            )
            previous_size = self.size
            self.size = int(previous_size * 0.75)
        elif tag == '/sup':
            context = self.buffer_line.pop_context()
            self.size = int(context.restore_size)
        elif tag == '/sub':
            context = self.buffer_line.pop_context()
            self.size = int(context.restore_size)

연습문제 3.5 : <pre> 태그 지원

요구사항은 오히려 3.2에서 <sup> / <sub> 지원하는 것보다 훨씬 간결하다. <pre> 태그는 공백과 개행을 그대로 유지해야 하는데, 일반 텍스트 렌더링처럼 단어 단위로 쪼개거나 공백을 합치면 이 특성이 깨진다. 그래서 <pre>가 열리면 pre_tag_depth를 1 증가시켜 “pre 텍스트 모드”임을 표시하고, 이 상태에서는 단어가 아닌 라인 단위로 텍스트를 처리하도록 했다. splitlines(keepends=True)로 줄바꿈 문자를 포함해 순회하며 각 줄 끝에서 flush()를 호출하면, 원본의 줄 구조와 들여쓰기가 그대로 보존된다. 닫히는 태그를 만나면 pre_tag_depth를 1 감소시켜 다시 일반 렌더링 모드로 복귀한다.

크게 보면 이 정도 차이만 있다.

if self.pre_tag_depth > 0:
    # pre 태그 안에 들어갔을 때 처리하는 방식. (단어 단위가 아닌 줄 단위로 처리한다.)
    lines = tree.text.splitlines(keepends=True)
    for line in lines:
        self.process_word(line)
        if line.endswith('\n'):
            self.flush()
else:
    # 기존의 처리 방식
    for word in tree.text.split():
        self.process_word(
            word if not self.small_caps else word.upper()
        )
7

No comments

If you have a fediverse account, you can comment on this article from your own instance. Search https://hackers.pub/ap/articles/019a663f-0b9d-7cdc-9133-3898303b8622 on your instance and reply to it.