Markdown으로 아름다운 pdf 문서 작성하기 - LaTeX, pandoc, nvim, zathura

Markdown으로 아름다운 pdf 문서 작성하기 - LaTeX, pandoc, nvim, zathura

Obsidian까지 Neovim으로 대체하고 싶어.

Obsidian을 neovim으로 완전히 대체하고 싶단 생각이 주기적으로 들었다. 아마 vim을 통해 모든 문서작업을 처리하고 싶은 마음 때문이 아닐까 싶다. 논문 작성도 대체했고, 프레젠테이션 작성도 대체했으니, 이제 오직 zettelkasten에 사용하는 obsidian만 남은 상태다.

Obsidian을 대체하기 위해, 필수적으로 구현해야 할 기능들을 꼽아봤다.

  1. Back link를 통해 메모들 간의 연결성 확보:
    zettelkasten을 구성하는 데 필수 요소이다.
  2. Markdown 파일의 미리보기 가능:
    텍스트 기반으로만 markdown을 표시하면 안된다. 컴파일 상태가 눈에 보여야 된다. 특히, 수식이 있을 때 그렇다.
  3. 메모 간 연결과 tag에 기반한 유사 메모 검색 기능:
    obsidian의 local graph 대체를 위한건데, 이게 가장 까다로울 것 같다.

그래서, 가장 쉬운 것부터 차근차근 연구해보고 있는 중이다. 이번 포스팅에서는 2번 Markdown 파일의 미리보기 기능을 구현한 방식을 공유하고자 한다.

준비물이 좀 많은 편 - texlive, pandoc, nvim, zathura

준비물이 좀 많은 편이다. 우리는 texlive, pandoc, nvim, zathura 네 개를 조합하여, nvim으로 작성한 markdown 파일을 작성, pdf로 변환하고, 미리보기 한다. 그리고 markdown 파일의 buffer가 수정, 저장될 때마다 자동으로 변환 작업이 수행되게 할 것이다.

각각의 패키지들이 연계되어 동작하는 방식은 다음과 같다.

  graph LR
    subgraph Editor
      A["neovim"]
    end
    subgraph Converter
      B["pandoc"]
      C["LaTeX<br>(XeLaTeX)"]
    end
    subgraph Viewer
      E["zathura"]
    end

    A -->|md| B -->|tex+template| C -->|pdf| E

자 그럼, 위 네 개의 패키지를 먼저 설치해보자. texlive 설치는 ‘LaTeX을 써보자 - TeX Live, vscode, LaTeX Workshop’를 참고하자.

pandoc, nvim, 그리고 zathura는 늘 하던대로 다음처럼 설치한다.

bash
sudo pacman -S pandoc
bash
sudo pacman -S neovim
bash
# zathura는 pdf 출력을 위한 의존 패키지인 zathura-pdf-mupdf도 함께 설치해야 한다.
sudo pacman -S zathura zathura-pdf-mupdf 

여기선 nvim의 플러그인 매니저로 lazy를 사용한다. Markdown preview를 위한 간단한 lua code를 작성하고, lazy load하여 사용하기 위함이다.

간단하게 nvim+lazy환경을 구축하려면 kickstart.nvim 을 사용하는 것이 편리하다1 2.

pdf 변환을 위한 pandoc template 작성

필수 요소들을 챙겼다면, 먼저 pdf 변환에 사용할 pandoc template을 만들어보자. pandoc은 LaTeX에 기반하여 markdown을 pdf로 변환한다. (정확히는 md -> tex -> pdf 순서로 변환)

이때 별도의 template이 없다면 default 설정으로 변환을 하게 되는데, 한국인은 100% 문제에 직면하게 된다. 기본적으로 LaTeX, pandoc 등은 영어를 전제로 만들어졌기 때문에, 한글 인코딩을 처리하지 못하고 에러를 뿜는다.

그래서 귀찮지만, 우리가 default로 사용할 template을 작성해줘야 한다. 작성할 template은 두 개다. (이름은 필자가 임의로 정한 것이다.)

  1. pdf.yml :
    md를 pandoc에 전달할 때 pandoc 명령어에 많은 옵션들을 사용하게 되는데, 이들을 yml 파일에 정리하고, pandoc이 이를 참고하게 한다. 여기에는 용지규격, 폰트 크기, 여백 등이 포함된다.
  2. header.tex:
    tex에 추가할 premble을 여기에 작성한다. 이 template은 앞서 pandoc의 옵션만으로 다룰 수 없는, LaTeX 수준에서 다뤄야할 부분을 설정하는 기능을 한다. 여기에는 한글 폰트와 영문폰트 설정, 필요 패키지 load 등이 포함된다.

필자가 작성한 각 template의 내용은 다음과 같다. 필요하다면 더 많은 옵션을 추가해도 된다.

pdf.yaml
from: markdown
to: pdf
pdf-engine: xelatex # 보유한 한글 ttf폰트를 사용하기 위해 xelatex을 사용한다.

toc: false                 # 목차 비활성 (원하면 true)
toc-depth: 3               # 목차 표기 시 깊이 설정
number-sections: false     # 섹션 번호 매기기 비활성
standalone: true

# 문서 메타데이터(언어 등)
metadata:
  lang: en                 # 문서 언어(ko/en)

variables:
  documentclass: extarticle # article, book, extarticle 등 LaTeX의 doc class를 설정
  classoption: [a4paper]   # a4paper, b5paper 등 latex의 용지 옵션을 설정

  # article/report 표준: 10/11/12pt, ext~ 양식은 14/16pt 등 더 큰 글자 사용 가능.
  fontsize: 14pt

  # 줄간격
  linestretch: 1.4 

  # 용지 여백 설정
  geometry: top=30mm,bottom=30mm,left=25mm,right=25mm 

  # 하이퍼링크 색상 사용 여부
  colorlinks: true 
header.tex
% kotex 패키지 사용
\usepackage[cjk]{kotex}
\usepackage{amsmath,amssymb}

% 들여쓰기 사용
\usepackage{indentfirst}
\setlength{\parindent}{1.5em}  % 원하는 들여쓰기 크기
\setlength{\parskip}{0pt}    % 문단 간 여백 제거

% english font 설정
\setmainfont{NanumMyeongjo}
\setsansfont{NanumGothic}
\setmonofont{D2Coding}

% hangul font 설정
\setmainhangulfont{NanumMyeongjo}
\setsanshangulfont{NanumGothic}
\setmonohangulfont{D2Coding}

% hyperlink의 색상 설정
\usepackage[svgnames]{xcolor} % DarkBlue, Crimson 등 확장 색 이름 사용
\makeatletter
\@ifpackageloaded{hyperref}{}{\usepackage{hyperref}}
\makeatother
\AtBeginDocument{
  \hypersetup{
    colorlinks=true,
    linkcolor=DarkBlue,   % 내부 링크(목차/섹션)
    urlcolor=Teal,     % URL 링크
    citecolor=DarkGreen,  % 인용(참고문헌)
    filecolor=Teal
  }%
}

작성한 template은 나는 ~/.pandoc/defaults 경로에 저장해뒀다. 어디에 위치하든 입력 시 정확한 경로만 입력해주면 되므로, 편한 곳에 저장해주자.

markdown to pdf 변환을 수행하는 lua 플러그인 작성하기

우리는 lazy가 불러올 수 있는 플러그인 형태로 lua 코드를 작성할 것이다. 우리가 건드릴 부분은 .config/nvim/lua/custom이고, 최종 구성은 다음과 같이 될 것이다.

            • init.lua
            • zk-preview.lua
              • init.lua
      • init.lua
  • 다음 두 개의 파일을 작성하고, 위의 폴더 구성과 동일하게 맞춰주자. 첫번째 파일은 작성한 플러그인을 load하는 기능을 하며, 두번째 파일은 실제 md to pdf 변환 기능을 하는 함수를 정의한다.

    .config/nvim/lua/custom/plugins/zk-preview.lua
    -- custom/zk-preview라는 플러그인이 markdown 파일을 열때만 lazy load하도록 설정하는 파일
    return {
      dir = vim.fn.stdpath 'config' .. '/lua/custom/zk-preview',
      ft = { 'markdown' }, -- Markdown 파일 열릴 때만 로드
    }
    .config/nvim/lua/custom/zk-preview/plugin/init.lua
    -- === ZK Markdown → PDF (on save) ===
    -- markdown을 pdf로 변환하는 함수를 정의하는 파일로, 다음의 형태로 동작한다.
    -- 요구사항:
    --  - pandoc, xelatex (TeX Live), zathura
    --  - header.tex: \usepackage{kotex} 등 한글/수식 설정
    --
    -- 동작:
    --  - *.md 저장 시 pandoc으로 /tmp/<파일이름>.pdf 빌드
    --  - 성공하면 zathura가 그 PDF를 이미 열었는지 검사
    --  - 안 열려 있으면 zathura를 (백그라운드로) 실행
    --  - 열려 있으면 zathura의 자동 리로드를 활용 (새로 안 띄움)
    
    local M = {}
    
    -- 본인 환경에 맞게 'header.tex' 경로 지정
    M.header_tex = vim.fn.expand '$HOME/.pandoc/defaults/header.tex'
    
    -- 선택: 이미지 등 리소스가 저장될 경로, :를 통해 여러개를 작성 가능.
    -- 현재는 '.'과 '100 Attachment'를 지정. 
    -- 필요없으면 '.'만 남기면 됨.
    M.resource_path = '.:100 Attachment'
    
    -- pandoc 인자
    M.pandoc_args_base = {
      '-f',
      'gfm+tex_math_dollars', -- $$ 수식을 인식을 위함.
      '--pdf-engine=xelatex', -- 폰트 사용을 위해 pdf 엔진으로 xelatex을 사용
      '--defaults=' .. vim.fn.expand '$HOME/.pandoc/defaults/pdf.yaml', -- pandoc의 옵션을 사전에 만든 파일로 전달
    }
    
    -- 저장 트리거 enable/disable 토글, default는 false
    M.enabled = false
    
    -- 수동 실행용 커맨드 (:ZkPreviewBuild)
    vim.api.nvim_create_user_command('ZkPreviewBuild', function()
      local buf = vim.api.nvim_get_current_buf()
      M.build_pdf_for_buf(buf)
    end, {})
    
    -- 토글 커맨드 (:ZkPreviewToggle)
    vim.api.nvim_create_user_command('ZkPreviewToggle', function()
      M.enabled = not M.enabled
      vim.notify('ZK Preview on save: ' .. (M.enabled and 'ENABLED' or 'DISABLED'))
    end, {})
    
    -- 내부: 현재 버퍼를 PDF로 빌드
    function M.build_pdf_for_buf(buf)
      local src = vim.api.nvim_buf_get_name(buf)
      if src == '' or vim.fn.filereadable(src) == 0 then
        return
      end
      if vim.fn.fnamemodify(src, ':e') ~= 'md' then
        return
      end
    
      -- 현재는 /tmp에 pdf를 저장하고 있음. 필요시 다른 경로로 설정 가능.
      local out_pdf = '/tmp/' .. vim.fn.fnamemodify(src, ':t:r') .. '.pdf'
    
      -- pandoc 실행을 위한 명령어 구성
      local args = { 'pandoc', src, '-o', out_pdf }
      for _, a in ipairs(M.pandoc_args_base) do
        table.insert(args, a)
      end
      if M.header_tex and M.header_tex ~= '' then
        table.insert(args, '--include-in-header=' .. M.header_tex)
      end
      if M.resource_path and M.resource_path ~= '' then
        table.insert(args, '--resource-path=' .. M.resource_path)
      end
    
      -- pandoc으로 빌드 실행
      vim.fn.jobstart(args, {
        stdout_buffered = true,
        stderr_buffered = true,
        on_exit = function(_, code)
          if code == 0 then
            -- zathura가 이미 out_pdf를 열고 있는지 점검
            vim.fn.jobstart({ 'ps', '-C', 'zathura', '-o', 'args=' }, {
              stdout_buffered = true,
              on_stdout = function(_, data)
                local opened = false
                for _, line in ipairs(data or {}) do
                  if line and line:match(vim.pesc(out_pdf)) then
                    opened = true
                    break
                  end
                end
                if not opened then
                  -- 열린 zathura 창이 없으면 새로 오픈 (백그라운드 포크)
                  vim.fn.jobstart { 'zathura', '--fork', out_pdf }
                end
              end,
            })
            vim.schedule(function()
              vim.notify('PDF built: ' .. out_pdf, vim.log.levels.INFO)
            end)
          else
            vim.schedule(function()
              vim.notify('Pandoc build failed (exit ' .. tostring(code) .. ')', vim.log.levels.ERROR)
            end)
          end
        end,
      })
    end
    
    -- toggle enabled 상태에서 저장을 인지하면 자동으로 pdf 변환을 수행
    vim.api.nvim_create_autocmd('BufWritePost', {
      pattern = '*.md',
      callback = function(args)
        if M.enabled then
          M.build_pdf_for_buf(args.buf)
        end
      end,
    })

    custom plugin 설정을 import하도록 설정하기

    자, 이제는 custom 플러그인을 lazy가 load하도록 nvim의 메인 init.lua에 custom.plugins 부분을 주석해제해야 한다.

    ~/.config/nvim/init.lua
    -- 주석 제거 전
    ...
      --  Uncomment the following line and add your plugins to `lua/custom/plugins/*.lua` to get going.
      -- { import = 'custom.plugins' },
    ...

    주석을 제거하여 다음처럼 만들고 저장해주자.

    ~/.config/nvim/init.lua
    -- 주석 제거 후
    ...
      --  Uncomment the following line and add your plugins to `lua/custom/plugins/*.lua` to get going.
      { import = 'custom.plugins' },
    ...

    자 그러면, nvim을 열고 :Lazy 명령어를 수행해보자. 그럼 플러그인 목록에 우리가 만든 zk-preview가 보일 것이다.

    Lazy 플러그인 목록에 zk-preview가 보여야 한다.
    Lazy 플러그인 목록에 zk-preview가 보여야 한다.

    이제 pdf로 변환을 위한 준비는 끝났다. 그럼 다음으로 사용법을 알아보자.

    zk-preview 사용법

    아무 markdown 파일이나 열어준다. 나는 다음의 markdown 파일을 사용한다.

    # Title: [[20240812111622 data manipulation-based distribution generalization]]
    
    Data generalization 기법의 세 가지 분류 계통 중의 하나로, 데이터들을 가공하여 모델이 Out-of-distribution 문제에 대해 좀 더 일반화된 특성을 학습할 수 있도록 만드는 방식이다.
    Data manipulation에 기반한 DG 기법이 OOD와 in-distribution 모두에서 유의미하게 예측할 수 있음을 실험을 통해 확인한 사례가 있다[^1].
    
    data manipulation을 통한 DG의 목표를 수식으로 표현하면 다음과 같다.
    이때 $\mathcal{M}(\cdot)$은 manipulation function이다.
    $$
    \min_h \mathbb{E}_{\mathbf{x},y} [\ell(h(\mathbf{x}), y)] + \mathbb{E}_{\mathbf{x}',y}[\ell(h(\mathbf{x}'), y)]
    $$
    $$
    \mathbf{x}' = \mathcal{M}(\mathbf{x})
    $$
    
    [^1]: 참조문헌 2
    
    ***
    Created : 2024-08-12 11:16:22
    
    Tags : [[out-of-distribution]] - [[generalization]] - [[preprocessing]]
    
    Related note : 
    
    References :  
    
    - J. Wang et al., “Generalizing to Unseen Domains: A Survey on Domain Generalization,” IEEE Trans. Knowl. Data Eng., pp. 1–1, 2022, doi: 10.1109/TKDE.2022.3178128.
    - D. Adila and D. Kang, “Understanding Out-of-distribution: A Perspective of Data Dynamics,” Nov. 29, 2021, arXiv: arXiv:2111.14730. Accessed: Aug. 12, 2024. [Online]. Available: http://arxiv.org/abs/2111.14730

    사용방법 1: ZkPreviewBuild로 수동 변환하기

    저장할 때마다 자동 변환하는 대신, 수동으로 단발성 변환을 할 때 사용하는 함수다. nvim의 커맨드에 :ZkPreviewBuild를 입력해주면, 이를 pdf로 변환한 후 zathura로 그 파일을 열어준다.

    사용방법 2: ZkPreviewToggle로 저장할 때마다 자동변환되게 하기

    매번 수동 변환을 하는 건 번거롭다. 자동 변환모드를 toggle하고, markdown이 저장될 때마다 자동으로 변환되게 해주자. nvim의 커맨드에 :ZkPreviewToggle을 입력하면, 자동 변환모드가 ENABLED된다. 그 다음부터는 저장할 때마다 알아서 변환을 수행할 것이다.

    자동 변환모드를 편하게 쓰려면, 다음의 keymap을 nvim의 메인 init.lua에 등록해서 쓰는 걸 추천한다.

    ~/.config/nvim/init.lua
    -- NOTE: markdown to pdf
    vim.keymap.set('n', '<leader>mm', '<cmd>ZkPreviewToggle<CR>', { desc = 'Toggle ZK PDF preview (on save)' })

    (참고) 변환 결과물

    예시의 md파일을 pdf로 변환한 결과는 다음과 같다. 아름답지 않은가?

    마무리하며

    오늘은 markdown을 pdf로 변환하기 위한 플러그인을 작성하고, 그 사용법을 알아봤다. 다음번에는 obsidian의 또 다른 기능을 nvim으로 구현해볼 계획이다. 그럼 이만…


    1. TJ DeVries의 영상 이 kickstart.nvim에 기반한 nvim 설치와 configuration을 아주 친절하게 설명해준다. ↩︎

    2. lazy만 독립적으로 설치하고자 한다면 lazy.nvim 공식 가이드 를 참고하자. ↩︎

    Last updated on