MakeAI 딥러닝

HWP 파일 속은 어떻게 생겼을까 — 레코드 스트림에서 표까지

HWP 5.0 은 OLE 복합 문서 안에 압축된 레코드가 줄줄이 들어 있는 구조예요. 파서를 직접 만들면서 알게 된 구조와, 명세에는 안 나오는 함정들을 정리해 봤어요.

5분 읽기

한국에서 문서 AI를 하면 HWP를 피할 수가 없어요. 공공기관 문서는 거의 다 HWP고, 금융권 내부 문서도 상당수가 그래요. 그런데 HWP를 "PDF로 바꿔서 OCR" 하는 식으로 다루면 표 구조, 각주, 문단 서식 같은 정보를 전부 버리게 돼요. 원본 파일에 이미 들어 있는 구조를 버리고, 이미지에서 다시 추측하는 셈이죠.

그래서 저희는 HWP를 그대로 읽는 네이티브 파서를 만들었어요. 이 글은 그 과정에서 알게 된 HWP 5.0 파일 구조를 정리한 거예요. 한글과컴퓨터가 공개한 문서 파일 형식 명세를 바탕으로 하되, 명세에는 안 적혀 있는 실무 함정도 같이 적어 볼게요.

1. 겉은 OLE 복합 문서예요

HWP 5.0 파일은 Microsoft의 OLE 복합 문서(Compound File Binary) 형식이에요. 파일 하나 안에 디렉터리와 스트림이 있는, 작은 파일 시스템이라고 생각하시면 돼요. 주요 스트림은 이래요.

스트림 내용
FileHeader 시그니처, 버전, 압축·암호화 여부 플래그
DocInfo 글꼴·문단 모양·스타일·문서 속성 같은 공유 정보
BodyText/Section0..N 실제 본문. 구역(section) 단위로 나뉘어요
BinData/* 삽입된 이미지·OLE 개체
PrvText, PrvImage 미리보기 텍스트·이미지

여기서 FileHeader의 압축 여부 플래그가 중요해요. 대부분의 파일은 DocInfo와 BodyText가 zlib(raw deflate)으로 압축돼 있거든요. 압축을 안 풀면 바이트가 전부 쓰레기처럼 보여요. 그리고 배포용 문서 플래그가 켜져 있으면 본문이 별도 암호화를 거치는데, 그건 오늘은 다루지 않을게요.

2. 안은 레코드가 줄줄이 이어져요

압축을 풀면 레코드(record) 가 연속으로 나와요. 각 레코드는 4바이트 헤더로 시작해요.

[ 태그 ID: 10비트 ][ 레벨: 10비트 ][ 크기: 12비트 ]
  • 태그 ID: 레코드 종류예요. 문단 헤더(HWPTAG_PARA_HEADER), 문단 텍스트, 문자 모양, 표(HWPTAG_TABLE), 셀 목록 같은 것들이요.
  • 레벨: 트리 깊이예요. 레코드는 평평하게 나열돼 있어서, 레벨 값으로 부모-자식 관계를 다시 만들어야 해요.
  • 크기: 데이터 길이예요. 4095(0xFFF)면 뒤에 4바이트 확장 크기가 하나 더 붙어요.

파서의 첫 단계는 이 평평한 레코드 열을 레벨 값으로 트리로 복원하는 거예요. 여기서 자주 하는 실수가 하나 있어요. 레벨이 두 단계 이상 갑자기 줄어드는 경우를 처리 안 하는 거예요. 표 안의 셀 안의 문단에서 바로 본문 문단으로 돌아올 때 그런 일이 생기거든요.

3. 문단 텍스트와 컨트롤 문자

문단 텍스트는 UTF-16LE예요. 그런데 모든 코드가 글자는 아니에요. 0~31 범위는 컨트롤 문자고, 그중 일부는 뒤에 7개 워드(14바이트)를 더 달고 있는 "확장 컨트롤"이에요.

코드 의미 추가 데이터
9 탭 있음
10 줄 바꿈 없음
11 그리기 개체/표 있음
13 문단 끝 없음
17 각주/미주 있음
30 묶음 빈칸 없음

이 컨트롤 문자를 그냥 건너뛰면 텍스트 오프셋이 밀리고, 문자 모양(HWPTAG_PARA_CHAR_SHAPE)이 가리키는 위치가 어긋나요. 굵게나 밑줄이 엉뚱한 글자에 붙는 버그는 거의 다 여기서 나와요. 문자 모양 레코드의 위치 값은 바이트가 아니라 글자 단위고, 확장 컨트롤은 8글자로 세야 해요. 저희도 이걸로 한참 헤맸어요.

4. 표는 "컨트롤"이에요

HWP에서 표는 문단 안에 들어 있는 컨트롤(코드 11)이에요. 그 컨트롤의 자식으로 HWPTAG_TABLE 레코드가 오고, 그 아래에 셀 수만큼 HWPTAG_LIST_HEADER 와 셀 안의 문단들이 와요.

HWPTAG_TABLE에는 행 수, 열 수, 각 행의 셀 수 배열이 있어요. 각 셀의 LIST_HEADER 뒤에는 셀 속성(열 주소, 행 주소, 열 병합 수, 행 병합 수, 폭, 높이)이 붙고요. 병합 셀은 여기에만 기록돼요. 병합된 셀은 셀 하나로만 존재하고 colspan/rowspan 값으로 크기를 표현하는, HTML이랑 같은 방식이에요. 그래서 Markdown 표로 바꿀 때 골치가 아파요. Markdown 표는 병합을 표현할 수 없으니까요. 이 얘기는 몇 주 뒤에 따로 쓸게요.

표 안에 표가 들어갈 수 있고, 셀 안에 각주가 들어갈 수도 있어요. 재귀로 처리하면 되긴 하는데, 레벨 값만 믿고 트리를 복원하면 중첩 표에서 셀 소속이 틀어지는 일이 있어서 저희는 LIST_HEADER의 문단 수 필드를 같이 써요.

5. 실무에서 만난 함정들

  • 같은 버전인데 구조가 달라요. 한/글 2007 이전 버전으로 저장한 파일은 일부 레코드 필드 길이가 달라요. FileHeader 버전을 보고 분기해야 해요.
  • 글꼴 ID는 언어별이에요. 문자 모양에는 한글·영문·한자·일어·기타·기호·사용자, 7개 언어의 글꼴 ID가 따로 있어요. 하나만 보면 글꼴이 틀려요.
  • 빈 문단이 문단이 아닐 때가 있어요. 표 바로 뒤의 빈 문단은 레이아웃용인 경우가 많아요. 그대로 바꾸면 쓸데없는 빈 줄이 생겨요.
  • BinData도 압축돼요. 이미지도 압축 플래그를 따라요. 그런데 어떤 파일은 이미지만 비압축이에요.
  • "마지막 문단" 플래그는 믿지 마세요. 구역의 마지막 문단을 알려주긴 하는데, 스트림 끝을 기준으로 하는 게 안전했어요.

6. 왜 굳이 직접 하냐면요

변환 도구로 PDF나 DOCX로 만든 다음 처리하면 쉬워 보여요. 하지만 그 과정에서 표의 병합 구조, 각주가 달린 위치, 문단 스타일 이름이 사라져요. RAG의 청크 경계, 추출 필드의 근거 위치, 서식 기반 조항 분리는 전부 이 정보에 기대고 있거든요.

네이티브 파서는 만들기 힘들지만, 원본이 갖고 있던 구조를 그대로 데이터로 넘길 수 있어요. 다음 주에는 같은 한글 문서의 새 형식인 HWPX 가 왜 다른지, 그리고 두 형식을 하나의 중간 표현으로 합치는 방법을 써 볼게요.