3-8. LineSeg 합성과 페이지네이션

LineSeg가 없다

rc.8까지 열심히 테이블 렌더링을 다듬었는데, 어느 날 테스트용 HWP 파일을 하나 열었더니 텍스트가 전부 겹쳐서 렌더링됐다.

처음에는 "뭐가 깨졌나?" 하고 코드를 디버깅했는데, 코드 문제가 아니었다. 그 파일에는 LineSeg 데이터가 아예 없었다.

알고 보니 HWP 5.1 이상 버전에서 작성된 일부 문서는 LineSeg를 포함하지 않는 경우가 있다. 한컴 워드프로세서가 문서를 저장할 때 LineSeg를 생략하는 옵션이 있는 것 같다.

LineSeg 없이 어떻게 줄 분할을 하지? 한컴 원본 뷰어는 이런 문서도 잘 렌더링하는데?

답은 **LineSeg를 합성(synthesize)**하는 것이었다.

LineSeg 합성이란 2026-03-15

LineSeg 합성은 문단의 텍스트와 스타일 정보만으로 줄 분할 위치를 추정하는 것이다.

한컴 원본 뷰어도 내부적으로 이런 작업을 하고 있을 텐데, 그쪽은 렌더링 엔진이 있으니까 정확한 텍스트 메트릭을 알 수 있다. 하지만 내 경우는 러스트 코어 라이브러리에서 텍스트 렌더링 없이 줄 분할을 추정해야 했다.

글자 폭 근사

가장 핵심적인 문제는 글자의 폭을 어떻게 알 것인가였다.

웹 브라우저처럼 실제 폰트 파일을 로드해서 글리프 메트릭을 읽으면 정확하겠지만, 그건 의존성과 복잡도가 너무 높다.

그래서 간단한 근사값을 사용하기로 했다:

fn estimate_char_width(ch: char, font_size: f64, is_hangul: bool) -> f64 {
    if is_hangul || ch.is_cjk() {
        // 한글/CJK 문자는 정사각형에 가까움
        font_size
    } else if ch == ' ' {
        // 공백은 약 0.25em
        font_size * 0.25
    } else if ch.is_ascii_alphanumeric() {
        // 영문/숫자는 약 0.5em ~ 0.6em
        font_size * 0.55
    } else {
        // 기타 문자
        font_size * 0.5
    }
}

정확한 값은 아니지만, 대부분의 한글 문서에서는 이 정도로 충분했다. 한글은 고정폭에 가깝고, 영문도 대부분의 본문 서체에서 비슷한 비율이기 때문이다.

물론 프로포셔널 폰트를 사용하는 경우에는 차이가 날 수 있지만, 완벽한 정확도보다는 "대부분의 경우 괜찮은" 수준을 목표로 했다.

줄바꿈 추정

글자 폭을 알면 줄바꿈 위치를 추정할 수 있다.

기본 알고리즘은 이렇다:

  1. 문단의 텍스트를 처음부터 읽으면서 각 글자의 폭을 누적
  2. 누적 폭이 줄의 가용 너비를 초과하면 그 위치에서 줄바꿈
  3. 줄바꿈 시 새로운 LineSeg를 생성
  4. 다음 줄은 들여쓰기 등을 적용한 가용 너비로 계산
fn synthesize_line_segments(
    &self,
    paragraph: &Paragraph,
    container_width: f64,
    para_shape: &ParaShape,
) -> Vec<LineSeg> {
    let mut segments = Vec::new();
    let mut current_x = 0.0;
    let mut current_y = 0.0;
    let mut line_start = 0;

    let indent = para_shape.indent_mm();
    let margin_left = para_shape.margin_left_mm();
    let available_width = container_width - margin_left - para_shape.margin_right_mm();

    // 첫 줄은 들여쓰기 적용
    let first_line_width = if indent >= 0.0 {
        available_width - indent
    } else {
        // 내어쓰기: 첫 줄이 더 넓음
        available_width + indent.abs()
    };

    let mut line_width = first_line_width;
    let text = paragraph.get_text();
    let font_size = self.get_font_size(paragraph, 0);
    let line_height = font_size * 0.75; // 대략적인 line_height

    for (idx, ch) in text.char_indices() {
        let char_width = estimate_char_width(ch, font_size, ch.is_hangul());
        current_x += char_width;

        if current_x > line_width {
            // 줄바꿈
            segments.push(LineSeg {
                text_start: line_start as u32,
                vertical_position: current_y as i32,
                line_height: line_height as i32,
                text_height: font_size as i32,
                baseline_distance: (font_size * 0.85) as i32,
                char_count: (idx - line_start) as u16,
            });

            line_start = idx;
            current_x = char_width;
            current_y += line_height;

            // 두 번째 줄부터는 내어쓰기가 아닌 일반 너비
            line_width = available_width;
        }
    }

    // 마지막 줄
    segments.push(LineSeg {
        text_start: line_start as u32,
        vertical_position: current_y as i32,
        line_height: line_height as i32,
        text_height: font_size as i32,
        baseline_distance: (font_size * 0.85) as i32,
        char_count: (text.len() - line_start) as u16,
    });

    segments
}

line_height 조정

line_heighttext_height * 0.75로 설정한 건 경험적인 값이다.

한컴 원본의 LineSeg 데이터를 분석해보면, line_height는 대체로 text_height보다 작다. 이건 줄 간격(line spacing)이 기본적으로 160%이고, 폰트의 내부 메트릭에 따라 달라지기 때문인데, 정확한 계산은 폰트 파일이 없으면 불가능하다.

0.75라는 값은 여러 HWP 문서의 LineSeg를 분석해서 "대부분의 경우 이 비율이면 얼추 맞더라"로 결정한 것이다. 완벽하진 않지만 대부분의 경우 한컴 원본과 1~2mm 이내의 차이로 렌더링된다.

테이블 셀 내 줄바꿈 추정 2026-03-15

LineSeg 합성이 본문 문단에서만 필요한 게 아니었다.

테이블 셀 안의 문단에서도 LineSeg가 없는 경우가 있었고, 이때는 셀의 너비를 기준으로 줄바꿈을 추정해야 했다.

셀 너비가 정해져 있으니 본문보다는 쉬울 거라 생각했는데, 그렇지 않았다.

셀 안에는 패딩이 있고, 셀 내부에 이미지나 도형이 있으면 가용 너비가 줄어들 수 있고, 셀 병합도 고려해야 했다.

fn synthesize_cell_line_segments(
    &self,
    paragraph: &Paragraph,
    cell: &CellInfo,
) -> Vec<LineSeg> {
    let cell_width = cell.width_mm()
        - cell.padding_left_mm()
        - cell.padding_right_mm();

    self.synthesize_line_segments(
        paragraph,
        cell_width,
        &self.get_para_shape(paragraph),
    )
}

그리고 셀 내에서 vertical_position을 누적하는 것도 중요했다. 셀 안에 여러 문단이 있을 때, 각 문단의 시작 위치는 이전 문단의 끝 위치에서 계속 누적되어야 한다.

이전에는 셀 내 문단의 vertical_position을 독립적으로 계산했는데, 이러면 여러 문단이 같은 위치에서 시작해서 겹쳐 렌더링됐다.

페이지 오버플로우 처리 2026-03-15

LineSeg 합성이 어느 정도 동작하자, 다음 문제는 페이지 오버플로우였다.

본문 텍스트가 페이지 높이를 초과하면 다음 페이지로 넘겨야 하는데, 합성된 LineSeg의 vertical_position을 보고 페이지 경계를 판단해야 했다.

fn apply_pagination(
    &mut self,
    segments: &[LineSeg],
    page_height: f64,
    margin_top: f64,
    margin_bottom: f64,
) -> Vec<PageBreak> {
    let content_height = page_height - margin_top - margin_bottom;
    let mut breaks = Vec::new();
    let mut current_page_start = 0.0;

    for (idx, seg) in segments.iter().enumerate() {
        let seg_bottom = seg.vertical_position as f64 + seg.line_height as f64;
        let relative_y = seg_bottom - current_page_start;

        if relative_y > content_height {
            breaks.push(PageBreak {
                segment_index: idx,
                page_offset: current_page_start,
            });
            current_page_start = seg.vertical_position as f64;
        }
    }

    breaks
}

여기서 주의할 점은 테이블이 페이지 경계에 걸리는 경우이다. 테이블은 LineSeg와 별개로 높이를 차지하기 때문에, 테이블의 높이도 vertical_position 누적에 반영해야 한다.

또한 이미지도 마찬가지이다. text_option=square인 이미지는 텍스트 흐름 안에서 자리를 차지하므로, 해당 높이만큼 vertical_position을 밀어줘야 한다.

2페이지 이상 렌더링

rc.9에서는 2페이지 이상의 문서를 렌더링할 수 있게 됐다.

핵심은 각 요소의 좌표를 페이지 기준 상대 좌표로 변환하는 것이다.

절대 좌표에서 현재 페이지의 시작 오프셋을 빼면 해당 페이지 내에서의 상대 좌표가 된다:

fn to_page_relative_y(absolute_y: f64, page_offset: f64) -> f64 {
    absolute_y - page_offset
}

단순한 뺄셈이지만, 이걸 모든 요소(텍스트, 테이블, 이미지, 각주 등)에 일관되게 적용하는 게 중요했다. 한 곳이라도 빠지면 해당 요소만 엉뚱한 위치에 렌더링된다.

to_html_pages API

기존 to_html API는 전체 문서를 하나의 HTML로 변환했는데, rc.9에서 to_html_pages API를 추가했다. 이 API는 페이지별로 분리된 HTML 배열을 반환한다.

웹에서 페이지 단위로 보여주고 싶을 때 유용하다. 예를 들어 페이지 네비게이션을 구현하거나, 특정 페이지만 렌더링하고 싶을 때 사용할 수 있다.

rc.10: 마무리와 배포용 문서 2026-03-15

rc.10은 비교적 작은 릴리스였다.

배포용 문서(Distribution Document) 지원

HWP에는 "배포용 문서"라는 개념이 있다. 일반 문서와 달리 편집이 제한된 형태로 배포되는 문서인데, 내부 구조가 약간 다르다.

배포용 문서는 BodyText의 데이터가 추가 암호화되어 있거나, 특정 플래그가 설정되어 있는데, 이걸 감지하고 적절하게 처리하도록 추가했다.

GSO 이미지 중복 렌더링 버그

테이블 셀 안에 GSO(Graphic Stored Object) 이미지가 있을 때, 동일한 이미지가 두 번 렌더링되는 버그가 있었다.

원인은 셀 내용을 렌더링할 때 CtrlHeader의 paragraphs와 셀의 paragraphs를 모두 순회하면서 같은 이미지를 두 번 처리한 것이었다.

CtrlHeader에서 이미 처리한 이미지는 셀 렌더링에서 건너뛰도록 수정했다.

Buffer polyfill 제거

rc.5(development-5)에서 WebAssembly 환경을 위해 Buffer polyfill을 만들었었는데, napi-rs가 업데이트되면서 더 이상 필요없게 됐다.

napi-rs 3.8.3에서 브라우저 환경의 Buffer 의존성이 제거된 것 같다. 아니면 내가 처음부터 잘못 이해하고 있었던 건지도 모르겠다.

어쨌든 불필요한 polyfill을 제거하니 코드가 깔끔해졌고, 사용자도 더 이상 Buffer polyfill을 신경 쓰지 않아도 된다. development-5에서 그 삽질을 기록해둔 게 민망해지는 순간이었다.

합성 LineSeg의 한계와 앞으로

합성 LineSeg는 "없는 것보다 낫다" 수준이다. 정확한 텍스트 메트릭 없이 글자 폭을 근사하는 것이기 때문에, 특수한 폰트나 복잡한 레이아웃에서는 차이가 날 수 있다.

특히 이런 경우에 문제가 된다:

  • 프로포셔널 영문 폰트에서 글자 간 폭 차이가 큰 경우
  • 혼합 언어(한글+영문+일본어) 텍스트
  • 수식이나 특수 기호가 많은 문서
  • 매우 작거나 매우 큰 폰트 크기

하지만 대부분의 한글 비즈니스 문서(공문서, 보고서, 계약서 등)에서는 충분한 정확도를 보여준다. 이런 문서들은 대부분 한글 본문에 기본 서체를 사용하기 때문이다.

앞으로 정확도를 높이려면 폰트 메트릭 파일을 내장하거나, WebAssembly 환경에서 브라우저의 measureText API를 활용하는 방법을 고려해볼 수 있다.

하지만 지금 당장은 이 정도면 충분하다고 판단했다. 완벽함을 추구하다 보면 영원히 릴리스를 못 하니까.

타임라인 정리

돌이켜보면 rc.5부터 rc.10까지 약 3주 동안의 작업이었다:

버전날짜주요 작업
rc.502-23HTML 뷰어 고도화, 스냅샷 테스트 도입
rc.603-01테스트 인프라, border priority 수정
rc.703-04다단, 마커, 각주/미주, 하이퍼링크, 도형
rc.803-14중첩 테이블, border fallback 전면 수정
rc.903-15LineSeg 합성, 페이지네이션
rc.1003-15배포용 문서, 버그 수정, polyfill 제거

3주 동안 6개의 릴리스를 뽑았는데, 특히 rc.8과 rc.9은 같은 날 릴리스됐다. 그날은 아침부터 밤까지 코딩만 한 날이었는데, 테이블 관련 이슈들이 한꺼번에 해결되면서 흐름을 놓치고 싶지 않았다.

이 기간 동안 가장 많이 배운 건 **"원본과 비교하며 역공학하는 것"**의 중요성이다. 스펙 문서만 보고 구현하면 "스펙대로 했는데 왜 다르지?"라는 상황이 자주 발생한다. 실제 원본 뷰어의 출력과 비교하면서 구현해야 진짜로 동작하는 코드가 나온다.

1줄 요약

rc.9~rc.10에서는 LineSeg 없는 문서를 위한 줄바꿈 합성과 다중 페이지 렌더링을 구현하고, Buffer polyfill을 제거했다.