[Part 3] 출력편: 브라우저에서 한글(HWPX)·PDF 파일 직접 만들기
결론부터
- 지면 값은 한 곳에서만 정한다. 두 군데 적어 두면 한쪽만 고치게 되고, 미리보기가 거짓말을 한다.
- 한글(HWPX)은 라이브러리가 없어서 규격을 보고 직접 조립했다.
- PDF는 처음에 브라우저 인쇄 창에 맡겼다가, 여러 건 내보내기 때문에 직접 조립하는 쪽으로 뒤집었다.
- 서식은 한 번만 해석해서 네 형식이 같은 값을 읽게 했다.
지면은 한 곳에서만 정한다
A4 지면의 치수는 이 한 줄이 전부다.
PAGE_MM = { width: 210, height: 297, marginTop: 20, marginSide: 20, marginBottom: 15 }처음에는 화면용 CSS와 인쇄용 CSS가 따로 있었다. 글자 크기, 행간, 여백이 조금씩 달라서 미리보기로 판단한 지면이 인쇄하면 다르게 나왔다. 미리보기가 거짓말을 하면 없느니만 못하다.
지금은 화면과 인쇄가 같은 CSS 문자열을 쓴다. 앱은 실행 중에 이 문자열을 <style>로 넣고, 내보내는 HTML은 같은 문자열을 인라인으로 넣는다.
단일 출처를 세 번 늘렸다
세 번 모두 같은 생각에서 나왔다. 두 군데 적어 두면 한쪽만 고치게 된다.
- 치수:
PAGE_MM - 글자 크기: 지면 CSS의 px 값을 그대로 pt로 환산한다 (1px = 0.75pt, 본문 13px → 9.75pt).
- 색과 선:
PAPER_COLORS
세 번째는 늦게 고쳤다. 이런 피드백을 받고 나서다.
여전히 프리뷰랑 살짝씩 다르게 나오는데 / 테이블 쪽도 너무 투박하고 선도 너무 진하고 두껍게 나오는데
읽기 전용 지면을 하나 더 둔다
편집기는 화면에 보이지 않는(display:none) 읽기 전용 지면을 하나 더 가진다. 편집 모드의 지면은 input과 contenteditable로 그려서 줄바꿈과 행 높이가 다르고, 행 추가 단추까지 종이에 찍히기 때문이다. 그래서 종이에는 이 읽기 전용 지면이 찍힌다.
네 가지 출력
한글(HWPX): 직접 조립
브라우저에서 .hwp 바이너리는 만들 수 없다. 대신 HWPX는 만들 수 있다. HWPX는 한글 문서를 OWPML(한글 문서의 XML 규격, KS X 6101)로 적어서 zip으로 묶은 형식이다. 쓸 만한 라이브러리가 없어서 규격을 보고 직접 조립했다.
조립하면서 지켜야 했던 규칙은 이렇다.
mimetype파일은 압축하지 않고 zip의 맨 앞에 와야 한다. 전자책 등에 쓰이는 OCF 규약과 같은 방식이다.- 글자, 문단, 테두리 모양은
header.xml에 미리 선언하고, 본문은 그 id를 참조한다. 참조한 id가 선언돼 있지 않으면 한글이 파일을 열다 만다.
본문 서식은 이렇게 풀었다. 색 6가지 × 크기 3가지 × 굵게·기울임·밑줄 = 144개의 글자 모양을 미리 선언해 두고, 글자 토막마다 계산으로 id를 고른다. 조합을 계산으로 낼 수 있으니 문서 전체를 훑어서 쓰인 서식을 모으는 단계가 필요 없다.
PDF: 인쇄 창을 버리고 직접 조립
원래 PDF는 window.print()에 맡겼다. 지면 CSS를 그대로 쓰니 미리보기와 똑같이 나왔다.
문제는 여러 건이었다. 문서 13건을 고르면 인쇄 창이 13번 뜬다. 브라우저는 만든 PDF 파일을 코드에 넘겨주지 않으니 zip으로 묶을 방법도 없었다.
그래서 꼼수를 썼다. 13건을 한 파일로 이어 붙여 인쇄 창을 한 번만 띄웠다. 그러면 받는 사람은 문서 13건이 아니라 한 덩어리를 받는다. 이 꼼수는 피드백 한 줄에 뒤집혔다.
여러 개의 파일을 이어 붙이는 게 아니고 한 개 한 개씩 zip 파일로 묶어줘야지
생각해 보면 Word와 한글은 이미 직접 조립하고 있었다. PDF만 예외일 이유가 없었다. pdfmake로 PDF를 직접 조립하고, 한글 글꼴(Pretendard, OFL 라이선스)을 파일에 넣었다.
이때 결정한 것은 세 가지다.
- 글자를 그림이 아니라 글자로 넣는다. 지면을 사진으로 찍어 넣는 방법도 있었다. 만들기 쉽고 미리보기와 똑같이 나온다. 하지만 복사도 검색도 안 되는 문서는 문서고가 만들 문서가 아니다.
- 글꼴을 파일에 넣는다. 받는 사람 기기에 어떤 글꼴이 있는지 알 수 없다. 글꼴 두 벌이 약 3MB라서, PDF 단추를 누를 때만 불러온다.
- 인쇄는 따로 남긴다. 프린터를 고르고 눈으로 확인하는 일은 파일을 만드는 일과 다르다.
목차 점선
목차의 점선(제목 ········· 3쪽)은 한 번 "못 넣는다"고 답했다. pdfmake의 목차 기능은 2칸 표로 고정돼 있고, 점선을 직접 그리면 쪽 번호를 셀 수 없다고 봤다.
다시 보니 방법이 있었다. 한 번 조판해서 각 절이 몇 쪽에 오는지 재고, 그 번호로 목차를 다시 그리면 점선과 쪽 번호가 둘 다 된다. 목차는 쪽 번호가 무엇이든 높이가 같으니, 두 번의 조판에서 쪽이 나뉘는 위치도 같다.
Word와 Markdown
- Word(DOCX):
docx패키지를 쓴다. 머리말에는 문서명을, 꼬리말에는PAGE / NUMPAGES필드를 넣었다. 쪽 번호는 Word가 직접 센다. - Markdown: 굵게, 기울임, 밑줄까지만 싣는다. 글자 크기와 색은 Markdown에 문법이 없어서 실을 수 없다. 못 하는 건 못 한다고 적어 뒀다.
서식은 한 번만 해석한다
화면의 서식은 HTML이지만 DOCX, HWPX, Markdown은 HTML이 아니다. 형식마다 HTML을 따로 해석하면 네 군데가 조금씩 다르게 나온다.
그래서 서식 문자열을 한 번 구조로 풀고, 네 형식이 그 같은 값을 읽게 했다. 이 파서는 DOM에 의존하지 않고 직접 만든 것이라, Node에서 도는 검사 스크립트도 같은 코드를 쓴다.
트러블슈팅
| 증상 | 원인 |
|---|---|
| 인쇄하면 여백이 사라짐 | 브라우저 인쇄 창의 '여백: 없음'이 @page를 덮어씀 |
| 인쇄물이 23.6mm 밀림 | 표 태그 사이 공백이 foster parenting으로 표 밖으로 끌려 나감 |
| 서식 단추를 눌러도 반응 없음 | 지면 끌기의 setPointerCapture가 mouseup을 삼킴 |
| ⌘Z가 안 먹음 | 서식을 입힐 때 innerHTML을 갈아 끼워 브라우저의 되돌리기 기록이 날아감 |
| 한글 입력 중 조합이 끊김 | 입력할 때마다 DOM을 다시 써서 IME 조합이 깨짐 |
| Word 여러 건 내보내기가 반응 없음 | Packer.toBuffer가 Node의 Buffer를 써서 브라우저에서만 멈춤 |
| PDF에서 '미작성'이 한 곳에만 나옴 | pdfmake가 조판하면서 객체를 고쳐, 여러 곳에서 공유한 객체가 첫 자리에만 남음 |
| 목차 쪽 번호가 빈칸 | JSON을 HTML 속성에 적을 때 따옴표를 바꾸지 않아 값이 {에서 끊김 |
| 표가 종이 밖으로 나감 | pdfmake의 widths는 칸 안쪽 폭이라 여백과 선이 그 위에 더해짐 |
| 한글과 영문이 섞인 열이 접힘 | 글자 수로 폭을 재서 '핵심 (North Star)'이 3줄로 갈림 |
| 휴지통 기한이 다시 늘어남 | 이미 지운 문서를 다시 지울 때 deletedAt을 새로 씀 |
| 계정 검사가 깨짐 | 지우기는 두 번 눌러도 결과가 같아야(멱등) 하는데, 없는 문서에 오류를 던지게 바꿈 |
이 중 네 건을 자세히 적는다.
① 인쇄 여백이 사라진다
@page { margin: 20mm }를 줬는데, 인쇄하면 종이 끝까지 꽉 차서 나왔다.
원인은 브라우저 인쇄 창의 '여백' 설정이었다. 여기를 '없음'으로 두면 @page의 여백이 통째로 무시된다. CSS로는 막을 방법이 없다.
그래서 여백을 콘텐츠로 만들었다.
- 좌우: 지면 상자의
padding으로 만든다. 가로 padding은 모든 쪽에 걸린다. - 위아래: 지면을 감싼 표의
thead/tfoot에 빈 행을 넣는다. 브라우저는 표가 여러 쪽에 걸치면thead/tfoot을 쪽마다 반복해서 그리고, 그만큼 자리도 차지한다. 그래서 둘째 쪽부터도 위아래 여백이 붙는다.
다 고치고 나서야 알았다. 여백이 없던 건 내가 내 브라우저 인쇄 설정을 '없음'으로 바꿔 놨기 때문이었다. 설정을 되돌리면 끝날 일이었지만, 고친 코드는 그대로 뒀다. 다른 사람의 브라우저 설정은 알 수 없고, 알 수 없는 것에 결과를 맡길 이유가 없다.
② 인쇄물 전체가 23.6mm 밀린다
위의 여백용 표를 문자열로 조립했는데, 태그 사이의 줄바꿈과 들여쓰기 공백이 문제였다.
HTML 파서는 <table> 안에 놓인 텍스트를 표 바깥으로 끌어낸다. 표 구조 안에는 글자가 올 수 없어서 생기는 동작이고, 이를 foster parenting이라고 부른다. 끌려 나간 공백이 빈 줄로 쌓여서 지면 전체가 23.6mm 밀렸다.
읽기 좋으라고 넣은 들여쓰기가 출력을 밀어낸 셈이다. 표 구조 태그 사이의 공백을 전부 없앴다.
③ 서식 단추가 눌리지 않는다
서식 단추를 누르면 mousedown은 단추에 닿는데, mouseup은 엉뚱한 곳에서 잡혔다.
원인은 지면을 확대했을 때 끌어서 옮기는 기능이었다. 이 기능은 pannable 옵션을 받도록 만들어 뒀고, 편집기는 false를 넘기고 있었다. 그런데 정작 코드가 그 값을 보지 않고, 내용이 넘치기만 하면 setPointerCapture로 포인터를 붙잡았다. 그래서 서식 단추도 안 눌리고, 글자를 끌어서 선택할 수도 없었다.
옵션을 만들어 두고 쓰지 않은 실수라서 타입 검사도, 검사 스크립트도 잡지 못한다. 브라우저에서 직접 눌러 보고서야 드러났다.
④ ⌘Z가 죽었다
서식을 입히면 브라우저는 <font size="5"> 같은 태그를 만든다. 저장하는 값을 일정하게 두려고, 이걸 우리 형식으로 정리해서 innerHTML에 다시 썼다.
그 순간 브라우저가 쌓아 둔 되돌리기 기록이 통째로 사라진다. 굵게를 한 번 누르고 나면 ⌘Z를 아무리 눌러도 아무 일도 일어나지 않았다. 글 쓰는 도구에서는 치명적이다.
해결은 이렇게 했다. 화면은 브라우저가 만든 대로 두고, 저장할 때만 정리한다. 브라우저가 만드는 <font size>는 지면 CSS에서 우리 서식의 세 단계 크기와 같게 맞춰 뒀다. 그래서 고치는 동안에도 저장된 결과와 같은 모습으로 보인다.
다음 편에서는 만들면서 내린 선택과 그 이유, 검사를 믿기 위해 쓴 방법, 그리고 틀렸던 것들을 정리한다.