한국에서 일하다 보면
.hwp/.hwpx를 피할 수가 없습니다. 관공서, 학교, 사내 양식까지 — "PDF 로 좀 바꿔 주세요" 한 줄이 의외로 무겁습니다. 이 글은 그 한 줄을 브라우저 안에서, 업로드 없이, 오프라인으로 해결하려고 만든 오픈소스rhwptopdf의 제작기입니다.
📺 온라인 데모 — https://sanguneo.github.io/rhwptopdf/
💻 GitHub — https://github.com/sanguneo/rhwptopdf

왜 만들었나
HWP 파일을 PDF 로 바꿔야 할 때, 보통 선택지는 두 가지입니다.
- 한컴 오피스를 설치한다. 1인 라이선스도 비싸고, 서버 자동화에는 더 부담스럽습니다.
- 온라인 변환 사이트에 올린다. 무료 SaaS 는 대체로 어딘가의 서버에 파일을 업로드합니다. 회사 문서나 개인 정보가 든 양식을 그렇게 던지긴 찝찝합니다.
저는 세 번째 선택지를 만들고 싶었습니다. 브라우저 안에서, 네트워크 없이, 사용자 PC 안에서만 끝나는 변환기.
출발점 — rhwp
HWP 파서를 제가 처음부터 짠 건 아닙니다.
HWP 5.0 binary spec 은 공개돼 있지만, 실제로 파싱하려면 OLE compound document, record-based 인코딩, 본문 흐름 / 표 / 도형 / 수식까지 — 학습 곡선이 만만치 않습니다.
다행히 edwardkim/rhwp 라는 훌륭한 Rust 구현체가 있었습니다.
- HWP 5.0 / HWPX 파서
- 페이지네이션과 SVG / Canvas 렌더링
- WebAssembly 빌드 가능
- Apache License 2.0
이미 "브라우저에서 HWP 를 보는" 부분은 이쪽에서 해결돼 있던 거죠. 다만 rhwp 는 뷰어 / 에디터 입니다. PDF 출력 파이프라인이 없습니다.
그래서 결정한 컨셉은 다음과 같습니다.
rhwp의 파서 + 레이아웃 렌더러 를 cherry-pick 해서, 그 위에 PDF 출력 한 가지 만 얹은 별개 트랙 라이브러리를 만들자.
rhwptopdf 라는 이름이 그대로 의도입니다. "rhwp to pdf", rhwp 가 만든 SVG 출력을 PDF 로 마무리하는 얇은 한 겹.
어떻게 동작하는가 — 5 단계 파이프라인
페이지 한 장당 다음 5 단계를 거칩니다.

1) SvgRenderer — HWP → SVG
rhwp 가 이미 잘 하던 일입니다. HWP 페이지 (본문, 표, 도형, 수식) 를 96 dpi 픽셀 단위의 SVG 로 그립니다. WASM 안에서 메모리 상으로만 일어나고, 디스크로 떨어지지 않습니다.
2) font-family 정규화
여기서 첫 번째 함정. HWP 의 doc_info 안에 들어 있는 폰트명이 가끔 깨져 있습니다. 어떤 문서에는 "신명조■얒a" 같은 식으로 — 한글 + 끔찍한 컨트롤 문자 조합. 이걸 그대로 PDF 폰트 매칭에 쓰면 매칭이 안 됩니다.
처음엔 sanitizer 를 강하게 짰지만, 결국 한 단계 더 단순화했습니다.
바탕 / 명조 / 궁서 / Batang / Myeongjo → serif
돋움 / 고딕 / 굴림 / Dotum / Gothic → sans-serif
SVG 안의 모든 font-family 를 두 가지 generic 으로 줄여 버리고, 실제 렌더는 fontdb 에 등록된 폰트가 담당합니다. "정확히 그 폰트로 그린다" 가 아니라 "비슷한 계열의 안 깨지는 폰트로 그린다" 쪽으로 트레이드오프 한 것.
3) usvg text-to-path
PDF 의 텍스트는 "어떤 글자를 어디에" 라는 추상 정보일 뿐이고, 글자가 어떻게 생겼는지는 PDF 뷰어가 가진 폰트에 달려 있습니다. 한글 폰트는 PDF 뷰어마다 다르고 — 특히 모바일 뷰어, 사내 뷰어에서 깨질 가능성이 큽니다.
그래서 usvg 를 써서 글자를 vector path 로 변환 해 버립니다. 모든 글자가 SVG 의 <path> 가 되고, PDF 안에서도 path 로 박힙니다. 뷰어가 어떤 폰트를 가지고 있든 상관없이 똑같이 보입니다.
대가는 fontdb 의존성입니다. 변환 시점에 폰트 데이터가 메모리에 있어야 글리프 outline 을 뽑을 수 있어요. 이게 다음 챕터로 이어집니다.
4) svg2pdf → 5) pdf-writer
svg2pdf::to_chunk() 가 SVG 한 페이지를 PDF XObject 청크로 변환하고, pdf-writer 가 여러 페이지의 청크를 묶어서 page tree 를 짭니다. media_box 는 96 dpi 픽셀을 × 72 / 96 으로 PT 로 환산해서 표준 A4 (595 × 842 pt) 가 되도록 맞췄습니다.
작은 함정 하나 더. 초반에 PT 환산을 빼먹어서 PDF 가 1.33× 부풀어 나왔습니다. SvgRenderer 출력이 픽셀이라는 걸 까먹은 거죠. 한 줄로 끝.
const PX_TO_PT: f32 = 72.0 / 96.0;
가장 오래 걸린 챕터 — 폰트
위 파이프라인의 3) 단계가 잘 동작하려면, fontdb 에 그 문서가 쓰는 글리프를 다 가진 폰트 가 등록돼 있어야 합니다. 한글이라는 게 11,172 자 (KS X 1001 완성형) 라, 부분 폰트만 가지고는 절반은 .notdef 로 나옵니다.
여러 단계를 거쳤습니다.
시도 1 — 한컴 함초롬 Ext 폰트
HAN*Ext 시리즈가 가장 먼저 떠올랐는데, 이름이 "Ext" 인 데에는 이유가 있더군요. 한글 음절 0 / 11172 자 — 확장 한자 / 특수 기호만 든 폰트였습니다. PDF 출력에서 한글이 완전히 사라지는 사태.
시도 2 — 함초롬바탕 / 함초롬돋움 (full)
HANBatang.ttf / HANDotum.ttf 로 교체. 11172 / 11172 자 모두 커버. 이제 글자가 보입니다. 다만 다음 문제 — WASM 번들에 6 MB 짜리 폰트 두 개를 박으면 라이브러리 크기가 폭발합니다.
시도 3 — 시스템 폰트를 먼저 써 보자
Font Access API 라는 게 있습니다. Chrome 105+ 부터 window.queryLocalFonts() 로 OS 에 설치된 폰트 목록을 (사용자 허가 하에) 읽을 수 있어요.
const fonts = await window.queryLocalFonts();
// [
// { family: "맑은 고딕",
// fullName: "Malgun Gothic",
// postscriptName: "MalgunGothic",
// blob() },
// ...
// ]
이 흐름이 데모 페이지의 기본값이 됐습니다.
- 페이지 로드 시
window.queryLocalFonts()시도 - 사용자가
local-fonts권한을 허락하면, 시스템에 설치된 한글 폰트 (맑은 고딕, 나눔고딕, 본고딕 등) 를 그대로registerPdfFont()에 넣음 - 권한 거부 or 미지원 브라우저 (Firefox / Safari) 면 정적 fallback 폰트 로 떨어짐 — 데모에서는
HANBatang.ttf/HANDotum.ttf를 별도 다운로드
라이브러리 자체 는 폰트를 번들하지 않습니다. registerPdfFont(bytes) API 만 노출해 두고, "그 바이트를 어디서 가져와서 넣는지" 는 호출자에게 맡깁니다. 데모 페이지만 정적 fallback 을 갖고 있을 뿐.
이게 라이브러리 사이즈를 6 MB (.wasm) + 16 KB (.umd.js) 로 유지할 수 있게 한 결정적인 트레이드오프였습니다.
시행착오 모음
PDF 출력이 처음엔 멀쩡해 보였는데, 실제 문서로 테스트해 보니 자잘한 버그가 줄줄이 나왔습니다.
"root2" 가 글자 그대로 박혀 나옴
수능 기출 한 문제로 테스트하다가 발견. HWP 수식이 PDF 에 root2 라는 raw 텍스트로 박혀 있었습니다. 수학 기호 √2 가 되어야 할 자리에. 원인은 rhwp 의 수식 토크나이저였습니다. root / sqrt 같은 키워드 뒤에 숫자가 붙으면 (root2, sqrt9) prefix-split 을 안 하고 통째로 식별자로 잡고 있었어요. 토크나이저에서 prefix 매칭 후 나머지를 별도 토큰으로 잘라내는 한 줄을 끼워 넣어 해결.
데모 페이지
데모 페이지 구현은 아래처럼 진행했습니다.
- 왼쪽: 드롭존 + "PDF 로 변환" 버튼 + 4-step stepper (분석 → 폰트 → 변환 → 완료)
- 오른쪽: PDF 미리보기 iframe
- 아래: 접을 수 있는 실행 로그
내부적으로는 HwpToPdfJob extends EventTarget 이라는 작은 wrapper 클래스가 변환 라이프사이클을 캡슐화합니다. progress / complete / error 이벤트를 발행하고, 데모 UI 는 이걸 구독해서 stepper 상태를 진행합니다. WASM 쪽에는 progress streaming 을 안 넣었습니다 — JS 만으로도 stepper 효과는 충분히 나오고, WASM 표면을 단순하게 유지하고 싶었어요.
사용법 — CDN 한 줄
배포된 UMD 번들을 그대로 끌어다 쓰면 됩니다.
<script src="https://github.com/sanguneo/rhwptopdf/releases/latest/download/rhwptopdf.umd.js"></script>
<script type="module">
// 1) WASM 초기화
await RhwpToPdf({ module_or_path: ".../rhwptopdf.umd_bg.wasm" });
// 2) 변환용 폰트 등록 (한 번씩)
const ttf = new Uint8Array(await (await fetch("/fonts/HANBatang.ttf")).arrayBuffer());
RhwpToPdf.registerPdfFont(ttf);
// 3) HWP → PDF
const hwpBytes = new Uint8Array(await file.arrayBuffer());
const pdfBytes = RhwpToPdf.hwpToPdf(hwpBytes); // Uint8Array
</script>
API 표면은 작습니다.
analyzeHwp(bytes): { pageCount, fontsRequired } // 메타데이터만 빠르게
hwpToPdf(bytes): Uint8Array // 본 변환
registerPdfFont(bytes): string // family name 자동 감지
clearPdfFonts(): void
extractThumbnail(bytes): unknown // PrvImage 추출 (옵션)
한계 / 솔직한 단점
자랑만 하면 곤란하니까 몇 가지 명확히 짚어 둡니다.
- fontdb 가 필수입니다. 글리프를 path 로 박는 트레이드오프의 대가입니다. 사용자가 폰트를 한 번은 넣어 줘야 합니다. (데모에서는 시스템 폰트로 우회했습니다)
- font-family 가 generic 으로 단순화됩니다. "이 문서는 굴림체로 통일" 같은 디테일은 보존되지 않습니다. 대신 어떤 환경에서도 깨지지 않습니다.
- 수식 / 도형 100% 호환 아닙니다.
rhwp의 렌더러가 커버하는 범위가 곧rhwptopdf의 한계입니다. 학회지 같은 복잡한 수식은 일부 어긋날 수 있어요. - 6 MB WASM. 모바일에서 초기 로딩이 가볍지는 않습니다. 첫 변환 후엔 캐싱되니 두 번째부터는 빠릅니다.
라이선스 — rhwp 에 대한 감사
- 자체 코드는 MIT 입니다.
rhwp에서 cherry-pick 한 parser / renderer 모듈은 Apache-2.0 그대로 유지됩니다.
Apache-2.0 § 4 는 재배포 시 attribution 과 변경 기록 보존을 요구합니다. NOTICE 파일에 정확히 어떤 모듈이 어디서 왔는지, 어떤 변경을 가했는지 적어 뒀습니다. 같은 영역 (한국어 문서 처리) 에서 이런 토대를 만들어 두신 Edward Kim 님과 rhwp 컨트리뷰터분들께 감사드립니다.
마무리
rhwptopdf 는 "있으면 좋겠다" 가 "있다" 가 된 결과물입니다. 지금은 v0.1.0 이고, 사용 중 발견된 버그 / 한계는 GitHub Issues 로 받습니다.
오프라인 PC 에서, 사내망 안에서, 모바일에서 — 어디서든 .hwp 한 장을 PDF 로 바꿔야 할 일이 생기면, 한 번 써 보고 의견 주세요.
- 📺 데모 — https://sanguneo.github.io/rhwptopdf/
- 💻 GitHub — https://github.com/sanguneo/rhwptopdf
읽어 주셔서 감사합니다.
