마크 다운을 PDF 문서로 변환하기

마크 다운을 HTML 문서로 변환하고, 다시 HTML 문서를 PDF로 변환하는 과정을 거치면 PDF 파일을 얻을 수 있다. HTML 파일을 PDF로 만드는 데는 파이썬으로 제작된 xhtml2pdf라는 패키지가 있긴 하지만 동작이 좀 불안정하거나 한글이 제대로 렌더링 되지 않는 (한글을 제대로 랜더링하는 방법은 있는데, 이 경우 CSS가 제대로 적용되지 않는다.) 등의 문제가 있어서 웹킷 엔진으로 렌더링하는 것과 같은 결과를 얻을 수 있는 PhantomJS를 사용하기로 결정했다.

개요

이 계획에 따라 일련의 스크립트들을 작성할 계획이다.

  • 모든 스크립트는 같은 디렉토리에 저장하며
  • 이 디렉토리는 PATH 환경 변수에 등록되어 있어서 명령줄에서 쉽게 호출 가능해야 한다.

필요한 패키지, 프로그램은 다음과 같다.

  • Python3 (Python2.6 이상에서는 일부 코드를 수정할 필요가 있다.)
  • PhantomJS
  • 파이썬 패키지
    • jinja2
    • markdown 혹은 markdown2

그리고 그외에 SCSS를 위한 파이썬 패키지인 sass를 설치해주면 좋다. (이 과정에서 사용할 것이다.)

변환 엔진

먼저 html 파일을 PDF로 렌더링하는 자바 스크립트 코드는 팬텀으로 하여금 페이지를 렌더링하도록 해주는 것이므로, 생각보다 매우 간단하다.

/*
    Javascript for Phantom
*/
var page = new WebPage();
var system = require("system");
page.paperSize = {
    format: "A4",
    orientation: "portrait",
    border: "2cm"
};
page.zoomFactor = 1;
page.open(system.args[1], function(status){
    page.render(system.args[2]);
    phantom.exit();
});

페이지 객체를 만들어서 페이지를 열면 (파일이든 URL이든 무관하다) 이 내용을 렌더링하는 콜백을 호출한다.

A4 기준으로 상하좌우 여백을 2cm 씩 주도록 했다. 테스트해보려면

c:/> phantomjs html2pdf.js http://naver.com test.pdf

와 같이 실행하면 네이버 첫 화면을 PDF로 만들 수 있음을 알 수 있다. (한글도 잘 나옴 ㅋ)

HTML 변환기

먼저, 마크다운을 HTML 파일로 컨버팅하는 스크립트를 만든다. markdown 패키지를 이용하여 컨버팅한다. 마크다운을 변경한 HTML은 본문 영역의 해당하는 내용이기 때문에 출력을 위한 모양새의 온전한 파일을 만들기 위해서 jinja2 엔진을 사용한다.

한가지 고민은 템플릿 파일 및 CSS 파일의 위치도 고정된 위치, 즉 절대 경로를 통해서 가져오도록 해야하는데 배치파일을 호출하는 위치에 상관없이 배치파일 위치를 기준 위치로 만들어야 한다. 따라서 os.path.split(os.path.abspath(sys.argv[0]))[0]을 사용해서 스크립트 파일의 경로를 그대로 사용하도록 했다.

#!python3
#-*-coding:utf-8

import sys
import os
import re
from markdown import Markdown
import jinja2

def main(md_filename, html_filename=None):
    if html_filename is None:
        html_filename = md_filename.rsplit('.', 1)[0] + '.html'
    md = Markdown()
    with open(md_filename, mode="r", encoding='utf-8') as forigin:
        data = forigin.read()
        _gist_id = 'sooop'
        data = re.sub(r'\{\{gist:([a-z0-9]+)\}\}',
                               r'<script src="https://gist.github.com/%s/\1.js"></script>'
                               % _gist_id,
                               data)
        # ??? {{gist:xxxxx#file-xxxxx.xx}}
        data = re.sub(r'\{\{gist:([a-z0-9]+)#file-([^-]+)-(.*)\}\}',
                               r'<script src="https://gist.github.com/%s/\1.js?file=\2.\3"></script>'
                               % _gist_id,
                               data)
        htmldata = render_html(md.convert(data))
        with open(html_filename, mode='w', encoding='utf-8') as ftarget:
            ftarget.write(htmldata)

def render_html(content):
    template_dir = get_curdir()
    template_filepath = get_filepath('template.html')
    template_file = open(template_filepath, 'r', encoding='utf-8')
    data = template_file.read()
    t = jinja2.Template(data)
    return t.render(content=content, abspath=get_curdir())

def get_filepath(filename):
    return os.path.join(get_curdir(), filename)

def get_curdir():
    return os.path.split(os.path.abspath(sys.argv[0]))[0]

if __name__ == "__main__":
    if len(sys.argv) != 3:
        print("Usage: md2html.py source.mdown target.html")
        exit(1)
    main(sys.argv[1], sys.argv[2])

템플릿

HTML 템플릿 파일과 main.css 파일이다.

<!doctype html>
<html>
<head>
<meta charset="utf-8">
<link rel="stylesheet" href="file:///{{abspath}}/main.css"/>
</head>
<body>
    {{content|safe}}
</body>
</html>

css 파일은 아래와 같다.

* {
      margin: 0;
      padding: 0;
      border: 0; }

    p, li {
      font-family: "Daehan", Serif;
      margin: 20px 0 20px 0;
      line-height: 1.4em; }
      p code {
        font-family: "Envy Code R", Monospace;
        font-size: 1.1em;
        font-weight: bold; }

    li {
      margin-left: 40px; }
      li :last-child {
        margin-bottom: 30px; }

    pre code {
      font-family: "Envy Code R", Monospace;
      display: block;
      margin: 30px auto 30px 40px;
      font-size: 0.9em;
      line-height: 1.3em; }

    h1 {
      margin: 50px auto 40px auto;
      font-family: "Daehan", Serif; }

    h2 {
      margin: 50px auto 40px auto;
      font-family: "Daehan", Serif; }

    h3 {
      margin: 50px auto 40px auto;
      font-family: "Daehan", Serif; }

MD2HTML.BAT

위 변환기를 호출하는 배치파일이다. 역시 파이썬 스크립트를 배치파일의 경로 위치를 토대로 호출한다.

@echo off
c:\python34\python.exe %~dp0\md2html.py %*
echo on

HTML > PDF 변환기

위의 HTML 변환기는 중간 결과물을 생성하는 것이고, 실제 스크립트는 위 스크립트를 실행한 중간결과물을 가지고 PDF 파일로 만들어주게 된다.

#!python3
#-*-coding:utf-8

import md2html
import sys
import subprocess

def main(md_filename, pdf_filename=None):
    if pdf_filename is None:
        pdf_filename = md_filename.rsplit('.', 1)[0] + '.pdf'
    html_filename = md_filename.rsplit('.', 1)[0] + '.html'
    md2html.main(md_filename, html_filename)
    js_filepath = md2html.get_filepath('html2pdf.js')
    subprocess.call(['phantomjs', js_filepath, html_filename, pdf_filename])


if __name__ == "__main__":
    if len(sys.argv) < 2:
        print('Usage: md2pdf html_filename pdf_filename')
        exit(1)
    main(sys.argv[1])

변환기 스크립트를 모듈로 반입하여 중간 결과를 생성하고, 이를 통해 PDF를 생성하는 프로그램(PhantomJS)을 호출한다. 파일 이름을 유지하기 위해서 rsplit() 함수를 활용하고 있음을 참고하자.

호출 스크립트는 다음과 같다.

@echo off
@REM make markdown to PDF
c:\python34\python.exe %~dp0\md2pdf.py %*
echo on

SCSS 컴파일러

css 작업 속도를 좀 빠르게 하기 위해서 vim에서 scss 파일을 생성, 저장하면 동일한 이름의 css 파일을 바로 컴파일 해주는 장치가 필요했다. 먼저 이를 위해 파이썬 스크립트를 하나 만든다.

#!c:/python34/python.exe

import sass
import sys

def main(scss_filename, css_filename=None):
    if css_filename is None:
        css_filename = scss_filename.rsplit('.', 1)[0] + '.css'
    with open(scss_filename, 'rb') as f:
        d = f.read()
        try:
            r = sass.compile_string(d)
        except:
            exit(1)
        with open(css_filename, 'wb') as c:
            c.write(r)

if __name__ == "__main__":
    if len(sys.argv) == 2:
        main(sys.argv[1])
    elif len(sys.argv) == 3:
        main(sys.argv[1], sys.argv[2])
    else:
        print("Usage: scss.py source.scss [target.css]")
        exit(1)

vim에서는 SCSS 파일을 저장한 직후에 위 스크립트를 돌려서 css 파일을 생성하도록 한다.

"TOPIC: Filetype SCSS {{{2
"--------------------------
augroup oSCSS
    autocmd!
    autocmd BufWritePost *.scss silent exec "!sass %"
augroup END

아래는 템플릿에 사용된 main.scss 파일이고

* {
    margin:0;
    padding:0;
    border:0;
}

$plain_font_stack: "Daehan", Serif;
$code_font_stack: "Envy Code R", Monospace;

p {
    font-family: $plain_font_stack;
    margin: 20px 0 20px 0;
    line-height: 1.4em;

    code {
        font-family: $code_font_stack;
        font-size:1.1em;
        font-weight: bold;
    }
}

li {
    @extend p;
    margin-left:40px;

    :last-child {
        margin-bottom: 30px;
    }
}

pre {
    code {
        font-family: $code_font_stack;
        display:block;
        margin:30px auto 30px 40px;
        font-size:0.9em;
        line-height:1.3em;
    }
}

@for $i from 1 to 4 {
    h#{$i}{
        margin: 50px auto 40px auto;
        font-family: $plain_font_stack;
    }
}

아래는 위를 컴파일한 결과물은 위의 css 파일이다.

참고

위 모든 소스는 gist 에 올려져 있다.

https://gist.github.com/sooop/32a0539dd352e0ff9ffe

이 문서를 PDF로 변환한 결과물은 여기에서 확인할 수 있다.