관공서 HWP 양식을 코드로 채우기, 함정 여덟 개
공모전에 자주 낸다. 그리고 한국의 공모전 신청서와 관공서 서식은 대부분 .hwp다. 표 구조가 고정된 템플릿에 값만 채워 넣는 형태고, 손으로 하면 한 건에 이십 분씩 걸리고 여러 건을 병행하면 반드시 어딘가 빈칸이 남는다.
그래서 프로그램으로 채운다. 이 글은 그 과정에서 밟은 함정 목록이다. 한국어로 된 자료가 거의 없어서 대부분 직접 부딪혀 알았다.
결론부터: 채우는 도구와 검증하는 도구를 분리한다
| 단계 | 도구 | 성격 |
|---|---|---|
| 구조 파악 | hwplib --dump |
헤드리스. 표 셀 평면 인덱스와 현재 텍스트 |
| 채움 | hwplib | 헤드리스 (Java) |
| 렌더 검증 | 한컴 오피스 COM | ground truth |
핵심은 마지막 줄이다. 채우는 건 라이브러리로 하고 판정은 한컴으로만 한다.
이유는 한컴의 페이지 나눔이 내용 기준 auto-fit이기 때문이다. 파일에 저장된 셀 높이 값을 읽어서 "이 표는 몇 mm니까 한 페이지에 들어간다"고 계산하면 틀린다. 한컴이 실제로 열어서 렌더한 결과만 사실이다.
그리고 COM으로 셀을 채우지 않는다. 이건 실측으로 배웠다. 셀 블록 상태에서 Delete가 듣지 않아 기존 템플릿 문구 위에 새 텍스트가 덧붙는다. 신청서 네 개 셀이 오염됐다. COM은 렌더 검증 전용이다.
도구 선택
hwplib 1.1.10 이상. Java 라이브러리고 그룹 아이디가 kr.dogfoot이다.
1.1.7을 쓰면 안 된다. addParagraph와 getParagraphs가 없다. 이걸 모르고 시작하면 "문단 추가가 왜 안 되지"에서 한참 헤맨다.
JDK가 필요하다. 참고로 다른 용도로 설치한 JRE가 있으면 javac가 없어서 컴파일이 안 된다. 별도로 JDK를 깔아야 한다.
함정 1: 새 문단을 만들면 서식이 전부 날아간다
가장 먼저 밟는 함정이다.
새 Paragraph를 만들어 텍스트를 넣으면 paraShapeId가 0이 된다. 들여쓰기, 글머리 기호, 정렬, 글꼴이 전부 사라진다. 양식의 시각적 골격이 무너진다.
해법: 양식의 기존 문단을 clone()한 뒤 텍스트만 교체한다. 서식 정보가 문단에 붙어 있으니 복제하면 따라온다.
함정 2: 셀 안 문단마다 서식이 다르다
함정 1의 해법을 적용하다가 두 번째 함정에 빠진다.
서약란처럼 한 셀에 여러 문단이 있는 경우가 있다. 그리고 그 문단들의 정렬이 서로 다르다. 본문은 좌정렬이고 기관명 줄은 가운데 정렬인 식이다.
첫 문단 하나를 복제해서 전체에 씌우면 가운데 정렬이 통째로 왼쪽으로 무너진다.
해법: i번째 줄은 i번째 원본 문단을 복제해서 서식을 물려받게 한다. 원본 문단 수를 초과하는 줄만 마지막 서식을 쓴다. 그래서 교체 텍스트의 줄 수를 원본 문단 수와 맞추는 게 가장 안전하다.
함정 3: 빈 문단에서 writer가 터진다
hwplib의 텍스트 쓰기 경로가 글자 리스트의 첫 번째 원소를 읽는다. 그래서 글자가 0개인 문단에서 인덱스 예외가 난다. 셀 안의 빈 줄이나 본문 사이의 빈 줄을 그대로 쓰면 저장 자체가 실패한다.
해법: 빈 줄은 공백 한 칸으로 채운다. 시각적으로 같고 writer를 통과한다.
함정 4: 셀 인덱스는 접근 경로마다 다르다
이게 문서에 인덱스를 하드코딩하면 안 되는 이유다.
COM으로 TableRightCell을 순회하며 세는 인덱스와, hwplib의 row.getCellList() 평면 인덱스가 병합 셀 때문에 어긋난다. 실측 사례에서 이메일 칸이 COM 기준 17번, hwplib 기준 16번이었다.
해법: 인덱스를 코드나 문서에 박기 전에 반드시 그 도구의 dump로 실측한다. 다른 도구에서 얻은 인덱스를 옮겨 쓰지 않는다.
함정 5: 타이틀만 1페이지에 남고 내용이 2페이지부터 시작한다
이건 원인을 찾는 데 가장 오래 걸렸다.
증상은 이렇다. 1페이지에 제목 한 줄만 있고 표가 통째로 2페이지로 넘어간다.
원인은 양식 구조다. 첫 표가 빈 상태에서도 높이가 241mm였다. 거의 한 페이지다. 그리고 한컴은 표를 행 단위로만 쪼갠다. 다음 페이지에 통째로 들어갈 수 있으면 안 쪼개고 통째로 옮긴다.
제목 11mm + 표 241mm = 252mm > 본문 영역 250mm
→ 표가 2페이지로 이동, 1페이지엔 제목만
저장된 셀 높이를 건드려서는 못 고친다. 한컴이 내용 기준으로 다시 계산하니까.
유일한 해법은 내용을 줄여서 첫 표의 실제 높이를 235mm 아래로 만드는 것이다. 첫 섹션을 핵심 수치만 남기고 압축하면 제목과 표가 한 페이지에 공존한다.
교훈으로 정리하면, 1페이지에 들어갈 표의 내용량을 처음부터 빡세게 다이어트한다.
함정 6: 셀 높이를 건드리면 빈 페이지가 생긴다
셀 높이를 조정할 일이 있으면 표 컨트롤 전체 높이도 같이 맞춰야 한다.
셀 높이만 바꾸고 컨트롤 높이를 그대로 두면 바운딩 박스가 불일치하고, 한컴이 빈 페이지를 넣거나 페이지를 점프한다.
해법: 셀 높이를 설정할 때마다 컨트롤 헤더의 높이를 행 높이 합으로 동기화한다. 이걸 적용해서 빈 페이지가 여덟 개에서 네 개로 줄었다.
높이를 추정할 때 참고할 수치가 있다. 줄당 약 1750 HWPUNIT이고, 줄당 글자 수는 들여쓰기 글머리를 감안해 28에서 30자로 잡는다. 전체 셀 폭으로 계산하면 과소추정되어 내용이 잘린다. 과대추정하면 빈 페이지가 생긴다. 한컴이 결국 auto-fit하니 약간 작게 잡는 편이 안전하다.
함정 7: 이미지 삽입은 절대 맨손으로 조립하지 않는다
가장 어려운 부분이다.
hwplib에서 ControlPicture 객체를 직접 만들어 필드를 채우면 안 된다. ctrlData, gsoId, pictureEffect 같은 필드 중 하나만 빠져도 한컴이 그 지점부터 문서를 통째로 잘라버린다. 20페이지 문서가 2페이지에서 끝난다.
디버깅 관점에서 이건 좋은 신호이기도 하다. 문서가 특정 지점에서 잘리면 그건 이미지 조립 오류다.
정답: 한컴이 직접 만든 아무 hwp 파일에서 ControlPicture를 통째로 복제한다. 그리고 바이너리 아이템 ID, 크기, 인라인 여부만 교체한다. 모든 필수 필드가 검증된 값으로 채워진 상태로 온다. 레퍼런스 파일은 이미지가 든 한컴 hwp면 아무거나 된다.
세부 규칙 몇 개.
- BinData 압축 옵션은 스토리지 기본값을 쓴다. 무압축으로 넣으면 한컴이 이미지 스트림을 못 읽어서 문서가 잘린다.
gsoId를 임의 값으로 설정하지 않는다. writer가 타입 디스패치에 실패해서ControlPicture를ControlLine으로 캐스팅하려다 예외가 난다. 복제하면 따라오니 건드리지 않는다.- 여러 장을 넣을 때는 이미지마다 레퍼런스를 새로 읽어 독립 복제한다. 하나의 복제본을 공유하면 오염된다.
- 검증은 엄격 파서로 한다.
pyhwp의hwp5proc cat으로 바이너리를 추출해서 PNG 매직 넘버89 50 4E 47이 나오면 임베드가 정상이다.
그리고 크기 문제가 하나 있다. 한컴은 복제한 그림을 약 0.75배로 축소 렌더한다. 복제본이 레퍼런스의 스케일 행렬을 들고 오기 때문이고, 행렬을 정규화해도 안 풀린다.
그래서 박스 폭에 꽉 채우려면 설정 폭을 1 나누기 0.75, 약 1.33배로 준다. 박스 셀 높이도 렌더된 크기, 즉 원래 높이의 0.75배에 맞춰서 아래 여백을 없앤다. 계수는 한컴 export로 미세 조정한다.
도식 자체를 만들 때도 주의할 게 있다. 박스 폭이 144mm인데 200mm 폭으로 도식을 만들면 28% 축소되어 글씨가 깨진다. 처음부터 170mm 폭 정도로 만들고 글씨는 9에서 11pt로 잡는다. 7pt는 안 읽힌다.
함정 8: 한컴 캐시가 헛수고의 절반을 만든다
이게 가장 허무한 함정이다.
한컴은 같은 파일명이 열려 있으면 디스크가 바뀌어도 새로 읽지 않는다. 그 상태로 PDF를 뽑으면 캐시된 옛 버전이 나온다.
수정했는데 계속 잘린 버전이 나와서 코드를 몇 번이나 다시 봤다. 증거는 바이트 크기였다. 출력 PDF 크기가 정확히 같았다.
해법 둘. 출력 파일명을 매번 새로 만든다. 그리고 한컴을 완전히 종료한 뒤 다시 연다. 그래야 깨끗하게 로드된다.
안 쓰기로 한 것들
여러 경로를 평가하고 버렸다. 남긴 이유가 다음 사람에게 쓸모가 있을 것 같다.
LibreOffice + H2Orestart. 폐기했다. 한컴과 다르게 동작해서 거짓 판정을 준다. "여기서는 되는데 한컴에서 깨지는" 함정에 빠진다. 붓글씨 폰트 오대체도 있었다.
rhwp (Rust 헤드리스 렌더러). 단순 폼은 표와 이중선과 한글 폰트까지 충실히 렌더한다. 그런데 복잡한 폼에서 깨진다. 플로팅 개체가 본문 위에 겹치고, 다중 페이지 표를 못 쪼개서 하단이 잘린다. 빠른 미리보기용으로는 쓸 수 있지만 최종 판정에는 못 쓴다.
Typst로 양식을 미러링하는 접근. 양식과 똑같이 생긴 문서를 다른 도구로 새로 그리는 방식이다. 폐기했다. 공식 양식을 제출하는 자리에 겉모습만 같은 문서를 내는 건 위험하다.
부수 규칙
- 가운뎃점(·)과 대시(—, –)는 출력에서 쓰지 않는다. 슬래시나 쉼표로 바꾼다. 단 양식에 원래 있는 공식 명칭의 가운뎃점은 원문 그대로 보존한다.
- 도식 이미지는 벡터로 만들어 PNG로 렌더한 뒤 위의 복제 방식으로 넣는다.
남은 질문
이 작업을 하면서 계속 든 생각이 있다. 함정 여덟 개 중 여섯 개가 "한컴이 파일에 적힌 값을 그대로 안 쓴다"에서 나왔다.
셀 높이는 다시 계산되고, 표는 통째로 옮겨지고, 그림은 0.75배로 줄고, 파일이 바뀌어도 캐시를 쓴다. 파일 포맷을 정확히 이해해도 렌더 결과를 예측할 수 없다.
그래서 이 도메인의 규칙은 하나로 압축된다. 파일을 믿지 말고 렌더 결과를 믿는다. 그리고 렌더는 실제로 제출물을 판정할 도구로만 한다.
다음에 비슷한 작업을 할 때 물어야 할 건 이거다. 내가 지금 보고 있는 미리보기가, 제출물을 받는 사람이 보게 될 화면과 같은 엔진에서 나왔는가?