3-6. HTML 뷰어 고도화

rc.4 이후의 세계

rc.4에서 HTML 뷰어의 기본 뼈대를 만들고, WebAssembly 배포까지 마쳤다. 솔직히 그때는 "이제 기본은 됐다"라고 생각했다.

하지만 실제 HWP 문서들을 열어보면 현실은 달랐다.

테이블이 좀만 복잡해지면 테두리가 깨지고, 캡션이 이상한 위치에 렌더링되고, 페이지가 넘어가면 내용이 겹치는 등 아직 갈 길이 멀었다.

rc.5를 향한 작업은 크게 다섯 가지로 나뉘었다:

  • 테이블 테두리 렌더링 개선
  • 테이블 캡션 처리
  • 페이지 분리(페이지네이션)
  • SVG 기반 테이블 테두리
  • LineSeg 개선

테이블 테두리의 지옥 2026-01 ~ 2026-02

HWP에서 테이블은 단순한 <table> 태그로는 표현이 안 된다.

한컴 원본 뷰어의 HTML을 분석해보면 테이블 테두리를 CSS border가 아니라 SVG로 그리고 있었다. 왜 그런지 처음에는 이해가 안 됐는데, 실제로 구현해보니 이유를 알겠더라.

HWP 테이블의 테두리는 셀마다 다를 수 있고, 인접한 셀의 테두리가 서로 다른 스타일일 때 어떤 걸 우선할지에 대한 복잡한 규칙이 있다.

CSS border-collapse로는 이걸 정확하게 표현할 수 없다.

SVG로 테두리 그리기

그래서 한컴 원본처럼 SVG를 사용하기로 했다.

테이블 위에 절대 위치로 SVG를 겹쳐놓고, 각 셀의 테두리를 <line> 요소로 그리는 방식이다:

fn render_table_border_svg(
    &self,
    table: &TableInfo,
    cells: &[CellInfo],
    row_positions: &[f64],
    col_positions: &[f64],
) -> String {
    let mut lines = Vec::new();
    let total_width = col_positions.last().unwrap_or(&0.0);
    let total_height = row_positions.last().unwrap_or(&0.0);

    // 가로선 렌더링
    for row_idx in 0..=row_positions.len() {
        for col_idx in 0..col_positions.len().saturating_sub(1) {
            let border = self.resolve_horizontal_border(
                table, cells, row_idx, col_idx
            );
            if border.line_type != 0 {
                lines.push(format!(
                    r#"<line x1="{}" y1="{}" x2="{}" y2="{}" stroke="{}" stroke-width="{}"/>"#,
                    col_positions[col_idx],
                    row_positions.get(row_idx).unwrap_or(&total_height),
                    col_positions[col_idx + 1],
                    row_positions.get(row_idx).unwrap_or(&total_height),
                    border.color.to_css(),
                    border.width_mm(),
                ));
            }
        }
    }

    // 세로선도 비슷하게...
    format!(
        r#"<svg class="htb-border" viewBox="0 0 {} {}">{}</svg>"#,
        total_width, total_height, lines.join("")
    )
}

이 코드가 단순해 보이지만, 핵심은 resolve_horizontal_border 함수에 있다.

테두리 우선순위 문제

HWP 테이블에서 가로 테두리를 그릴 때, 상단 셀의 bottom border와 하단 셀의 top border 중 어떤 걸 써야 할까?

한컴 원본 뷰어를 분석해보니 대략 이런 규칙이었다:

  1. 외곽선은 테이블 기본 border 사용
  2. 내부 행 경계에서는 아래 셀의 top border를 우선
  3. line_type=0(선 없음)이면 반대쪽 셀의 border를 fallback으로 사용

이걸 코드로 옮기는 게 진짜 힘들었다. 한컴 원본 HTML과 내 출력을 한 줄 한 줄 비교하면서 규칙을 역공학한 셈이다.

행/열 위치 계산

SVG로 테두리를 그리려면 각 행과 열의 정확한 위치를 알아야 한다.

행 위치는 셀의 높이를 누적해서 계산하고, 열 위치는 셀의 너비를 누적해서 계산한다. 그런데 HWP에서는 셀의 높이가 내용에 따라 달라질 수 있고, 병합된 셀도 있어서 단순히 순서대로 더하면 안 된다.

fn calculate_row_positions(
    &self,
    cells: &[CellInfo],
    row_sizes: &[u16],
) -> Vec<f64> {
    let mut positions = vec![0.0];
    let mut current_y = 0.0;

    for row_idx in 0..row_sizes.len() {
        let row_height = self.calculate_row_height(cells, row_idx);
        current_y += row_height;
        positions.push(current_y);
    }

    positions
}

여기서 calculate_row_height가 또 복잡한데, 해당 행에 속하는 모든 셀 중 가장 큰 높이를 사용해야 하고, row_size가 1보다 큰 병합 셀은 제외해야 한다.

이런 세세한 규칙들을 하나하나 맞추는 게 정말 지루하고 시간이 많이 걸렸다.

테이블 캡션 처리 2026-02

테이블 캡션도 만만치 않았다.

HWP에서 테이블 캡션은 CtrlHeader 안의 caption_info로 정의되는데, 위치(상/하/좌/우), 너비, 마진, 간격 등 여러 속성이 있다.

문제는 캡션이 있는 테이블과 없는 테이블의 HTML 구조가 달라야 한다는 것.

한컴 원본을 분석해보니:

  • 캡션이 있으면 htG(grouping) 컨테이너로 감싸고, 그 안에 테이블과 캡션을 배치
  • 캡션이 없으면 바로 htb(table body)만 렌더링

캡션의 위치에 따라 DOM 순서도 달라져야 했다:

  • 상단 캡션: 캡션 → 테이블
  • 하단 캡션: 테이블 → 캡션
  • 좌/우 캡션: 플렉스 레이아웃으로 나란히 배치

그리고 캡션 안에는 자동 번호(autonumber)가 들어갈 수 있어서, 테이블 캡션의 번호 추적도 구현해야 했다.

솔직히 캡션 하나 때문에 코드량이 엄청나게 늘어났는데, 실제 HWP 문서에서 캡션을 안 쓰는 경우가 더 많아서 이게 맞나 싶기도 했다.

하지만 스펙 문서에 정의된 이상 지원해야 한다고 생각했고, 나중에 공공문서 같은 곳에서 캡션이 많이 쓰이길래 결과적으론 잘한 판단이었다.

페이지네이션 2026-02

HTML 뷰어에서 페이지를 나누는 건 생각보다 복잡한 문제였다.

HWP 문서는 기본적으로 페이지 단위이고, SectionDef에 페이지 크기와 마진이 정의되어 있다.

rc.4에서는 단일 페이지만 렌더링했는데, 실제 문서는 당연히 여러 페이지가 있다.

페이지네이션의 핵심은 "이 요소가 현재 페이지에 들어가는가?"를 판단하는 것이다.

LineSeg 데이터가 있으면 각 줄의 vertical_position을 확인해서 페이지 경계를 넘는지 확인할 수 있다. 하지만 테이블이나 이미지 같은 블록 요소는 LineSeg와 별개로 높이를 계산해야 한다.

fn should_break_page(
    &self,
    current_y: f64,
    element_height: f64,
    page_height: f64,
    margin_bottom: f64,
) -> bool {
    current_y + element_height > page_height - margin_bottom
}

이 함수 자체는 단순한데, current_y를 정확하게 추적하는 게 어려웠다.

텍스트 문단, 테이블, 이미지, 각주 등 다양한 요소가 섞여있고, 각 요소의 높이를 정확하게 계산해야 현재 위치를 알 수 있기 때문이다.

그리고 페이지가 넘어가면 요소의 위치를 다음 페이지 기준으로 다시 계산해야 한다. 첫 번째 페이지에서의 y=500mm는 두 번째 페이지에서는 y=500-297=203mm가 되는 식이다.

이건 나중에 rc.9에서 더 본격적으로 다루게 되는 주제인데, rc.5 시점에서는 기본적인 페이지 분리만 구현했다.

한컴 원본 HTML과의 비교 2026-02

rc.5에서 가장 중요했던 작업 중 하나는 fixture 기반 스냅샷 테스트를 도입한 것이다.

한컴 원본 뷰어에서 생성한 HTML을 fixture로 저장해놓고, 내가 생성한 HTML과 비교하는 방식이다.

이걸 도입하기 전에는 "눈으로 보기에 괜찮은가?"로 판단했는데, 이러면 미묘한 차이를 놓치기 쉬웠다.

스냅샷 테스트를 도입하고 나니 차이점이 명확하게 보였다:

  • CSS 클래스 이름이 다름 → 한컴 원본의 클래스 명명 규칙을 따르도록 수정
  • 테이블 DOM 구조가 다름 → htb, htG, htC 등 한컴 원본의 구조를 따르도록 수정
  • 취소선(strikethrough) 파싱 오류 → 스펙 문서 재확인 후 수정
  • 연속 공백 처리 → &nbsp;로 변환하여 한컴 원본과 일치

이런 식으로 fixture와 내 출력을 diff로 비교하면서 하나씩 맞춰나갔다.

지루하지만 확실한 방법이었고, 나중에 리팩토링할 때도 스냅샷 테스트가 안전망 역할을 해줬다.

외부 기여자의 첫 PR

rc.5 개발 중에 @hulryung이라는 분이 첫 번째 외부 PR을 보내주셨다. http_quiv 타이포를 http-equiv로 수정하는 간단한 PR이었는데, 누군가가 내 코드를 보고 기여해줬다는 게 생각보다 기분이 좋았다.

오픈소스를 하면서 "누가 이걸 쓸까?" 하는 의문이 항상 있었는데, PR을 받으니까 최소한 관심을 가진 사람이 있다는 증거가 되어서 동기부여가 됐다.

LineSeg 개선

LineSeg는 HWP에서 줄 분할 정보를 담고 있는 구조체이다.

각 문단에는 LineSeg 배열이 있고, 각 LineSeg에는 해당 줄의 시작 위치, 수직 위치, 높이, baseline 거리 등이 들어있다.

rc.4에서는 LineSeg를 단순히 줄 높이 계산에만 사용했는데, rc.5에서는 이걸 더 정교하게 활용하기 시작했다.

특히 baseline_distance를 직접 사용해서 line-height를 계산하도록 변경한 게 큰 개선이었다.

이전에는 대략적인 값을 사용했는데, baseline_distance를 직접 사용하니 한컴 원본과 거의 동일한 줄 간격이 나왔다.

그리고 LineSeg 간에 스타일이 바뀌는 경우도 처리했다. 같은 문단 안에서도 줄마다 글꼴이나 크기가 다를 수 있는데, 이때 각 줄의 스타일을 올바르게 적용하도록 수정했다.

마무리하며

rc.5는 화려한 새 기능보다는 기존 기능의 완성도를 높이는 작업이었다.

테이블 테두리, 캡션, 페이지네이션, 스냅샷 테스트 도입 등 하나하나가 지루하고 시간이 많이 걸리는 작업이었지만, 이런 기반 작업이 있어야 나중에 더 복잡한 기능을 안정적으로 추가할 수 있다.

그리고 이 시기에 테스트 코드를 대거 추가했다. 단위 테스트만 약 70개 이상의 PR이 머지됐고, 각 모듈별로 기본 구조 테스트부터 엣지 케이스까지 촘촘하게 작성했다.

솔직히 테스트 코드 작성은 재미없는 작업이지만, AI한테 시키면 꽤 잘 해줘서 이 부분은 수월했다. 대신 AI가 만든 테스트가 의미있는지 검증하는 건 내 몫이었고, 그 과정에서 몇 가지 버그도 발견했다.

1줄 요약

rc.5에서는 테이블 SVG 테두리, 캡션 처리, 페이지네이션, 스냅샷 테스트를 도입해서 HTML 뷰어의 기반을 다졌다.