TeXファイルから数式を画像化したKindle本を作ろう


楽天KoboやiBooksはMathMLに対応しており，レンダリングもかなりよくなっていますが，最大手のAmazonのKindleは対応しておらず（費用対効果の関係なのか，おそらく今後対応させる気はなさそう），リフロー型の数学っぽいKindle本を作るには，数式を画像化するのが不本意ながら定石といえます．

数式を画像にする上で，せめてベクトル画像のsvgを用いたいところですが，これもKindleは対応しているのかしてないのかよく分からない状況（iOSのアプリだと対応していない？）なので，おとなしくpngにするのが無難そうです．しかし，数百以上の数式を盛り込んだtexなどの文書をすべて画像化して，それをepub3などのフォーマットにするのを手作業でやるわけにもいかないので，オートメイトするのがこの記事の主旨です．

## 注意点

環境はmacOSです．変数命名のセンスやディレクトリなどの取り扱いが素人丸出しかもしれませんが，ご了承ください．あとでもう少しスマートに書き換えるかもしれません．

## 特別に用いたもの

+ python 3.5
+ pandoc
+ texlive
+ imagemagick
+ Kindle Previewer(or kindlegen)

## 大まかな手順

1. texで文書作成
2. pandocでtex->html+mathjaxに変換
3. mathjaxの部分をpngにする
4. pandocでhtml->epub3に変換
5. epub3を解凍して手直しし，再圧縮
6. Kindle Previewerにepub3を突っ込んで，mobiを作成（kindlegenでもOK）

## とりあえずコード


```python
import subprocess, lxml.html, lxml.etree, imagesize, re, tempfile

def tex2html(source, target): #pandocのオプションは適宜変えてください
    subprocess.call(['pandoc',
                     '-s',
                     '-t', 'html5',
                     '--mathjax',
                     '--css=stylesheet.css',
                     '-o', target, source])    

def get_html(filename, encoding='utf-8', xml=False):
    # xhtmlをパースするときは lxml.etree を，htmlをパースするときは lxml.html を使う
    with open(filename, 'r', encoding=encoding) as f:
        if xml == True:
            html = lxml.etree.parse(f).getroot()
        else:
            html = lxml.html.parse(f).getroot()
    return html

def write_html(html, filename, encoding='utf-8', xml=False):
    # xhtmlをパースするときは lxml.etree を，htmlをパースするときは lxml.html を使う
    if xml == True:
        src = lxml.etree.tostring(html, 
                                  encoding=encoding,
                                  xml_declaration=True,
                                  doctype='<!DOCTYPE html>',
                                  method='xml',
                                  pretty_print=True).decode(encoding)
    else:
        src = lxml.html.tostring(html,
                                 encoding=encoding,
                                 doctype='<!DOCTYPE html>',
                                 pretty_print=True).decode(encoding)
    with open(filename, 'w', encoding=encoding) as f:
        f.write(src)

def convert2png(filename, newfilename, convert=True):
    html= get_html(filename)
    textemplate = r'''\documentclass[a0paper, uplatex]{jsarticle}
\usepackage[dvipdfmx]{graphicx}
\usepackage[margin=1cm]{geometry}
\usepackage{amsmath,amssymb}
\pagestyle{empty}
\begin{document}
\scalebox{4}{\parbox{.25\linewidth}{MATH}}
\end{document}
'''
    imgs = {} #同じ画像を作らないように，texコードと画像ファイルを記録しておく用
    for span in html.xpath(r'//span[@class="math inline" or @class="math display"]'):
        tex = span.text
        if tex in imgs:
            imgsrc = imgs[tex]
        else:
            imgsrc = r'math{0:04d}.png'.format(len(imgs)+1)
            imgs[tex] = imgsrc
            if convert == True:
                with open('tmp.tex', 'w') as texf:
                    texf.write(textemplate.replace('MATH', tex))
                subprocess.call('uplatex tmp.tex', shell=True)
                subprocess.call('dvipdfmx tmp.dvi', shell=True)
                subprocess.call('convert -trim tmp.pdf '+imgsrc, shell=True)
        span.tag = 'img'
        span.text = None
        span.attrib['src'] = imgsrc
        span.attrib['alt'] = tex
        width, height = imagesize.get(imgsrc)
        span.attrib['height'] = str(height)

    write_html(html, newfilename)
                           
def html2epub(source, target, css, cover):
    subprocess.call(['pandoc',
                     '-t', 'epub3',
                     '--toc',
                     '--epub-chapter-level=1',
                     '--epub-stylesheet', css,
                     '--epub-cover-image', cover,
                     '-o', target, source])

def extract_epub(filename, tmpdir):
    subprocess.call('mkdir {0}'.format(tmpdir), shell=True)
    subprocess.call('unzip -d {0} {1}'.format(tmpdir, filename), shell=True)

def make_epub(filename, tmpdir):
    subprocess.os.chdir(tmpdir)
    subprocess.call(r'zip -0 ../{filename} mimetype;zip -XrD ../{filename} *'.format(filename=filename, tmpdir = tmpdir),shell=True)
    subprocess.os.chdir('../')
    
def make_epub_for_kindle(name, css, cover, convert=True):
    tmpdir = name + '_tmpdir'
    if not subprocess.os.path.isdir(tmpdir):
        subprocess.os.mkdir(tmpdir)
    subprocess.os.chdir(tmpdir)
    tex2html('../'+name+'.tex', name+'.html')
    convert2png(name+'.html', name+'2.html', convert)
    html2epub(name+'2.html', name+'0.epub', '../'+css, '../'+cover)
    epubdir = tempfile.TemporaryDirectory(dir='./')
    extract_epub(name+'0.epub', epubdir.name)
    ns = {'xhtml': 'http://www.w3.org/1999/xhtml'}
    for filename in [fn for fn in subprocess.os.listdir(epubdir.name) if re.match(r'ch[\d]{3}.xhtml', fn)]:
        xhtml = get_html(epubdir.name+'/'+filename, xml=True)
        xhtml.attrib['{http://www.idpf.org/2007/ops}lang'] = 'ja'
        xhtml.attrib['lang'] = 'ja'
        for img in xhtml.xpath('//xhtml:img', namespaces=ns):
            height = int(img.attrib['height'])
            img.attrib['style'] = 'height: ' + str(round(height/40, 2)) + 'em;'
            del img.attrib['height']
        write_html(xhtml, epubdir.name+'/'+filename, xml=True)
    make_epub(name+'.epub', epubdir.name)
    epubdir.cleanup()
    subprocess.os.chdir('../')
```

## 各関数の説明

### `tex2html(source, target)`

`pandoc` を用いて，TeXファイルをHTML5に変換します．`--mathjax` オプションによって，数式の部分は

```html
<span class="math inline">\(e^{\pi i} = -1\)</span>

<span class="math display">\[e^{\pi i} = -1\]</span>
```

といった形で出力されます．この部分を`lxml`で抽出して順次画像化します．

### `get_html(filename, encoding='utf-8', xml=False)`

HTML文書を`lxml`でパースし，ルート要素である`html`要素を返します．デフォルトではHTMLを想定していますが，XHTMLをパースする場合は`xml=True`のオプションを追加します．

### `write_html(html, filename, encoding='utf-8', xml=False)`

`html`要素を引数にして，HTMLファイルを書き出します．

### `convert2png(filename, newfilename, convert=True)`

HTML文書内の

```html
<span class="math inline">\(e^{\pi i} = -1\)</span>
```

といった部分をimagemagickの`convert`で画像化し，

```html
<img src="math0001.png" class="math inline" alt="\(e^{\pi i} = -1\)" height="30">
```

という形式の`img`要素に置き換えていきます．`height`属性は，最後の数式の高さ調整で使うかなり重要な値です．これは`imagesize`というpythonのライブラリで拾っています．数式が多いと変換に時間がかかります．`convert=False`とすると，画像変換の手順をスキップします．手直しするときなど，再び変換する必要がない場合は`False`にしておくと時間短縮になります．

ちなみに生成する画像は少し大きめに作っています（初期設定の4倍で多分40ptくらい）．初期設定のまま画像を作ると，潰れて読めないので，大きめにして縮小するという手法を使っています．

ところで，texを画像化するのにあたってググってみると `dvipng` というものがヒットしますが，日本語変換に対応させるのは面倒くさそうなので，pdfをimagemagickの`convert`でpngに変換する方法がお手軽です．

### `html2epub(source, target, css, cover)`

数式の画像化が完了したHTML文書をpandocでepubに変換します．`css`は埋め込むstylesheetのファイル名，`cover`は埋め込む表紙画像のファイル名をいれます．

### `extract_epub(filename, tmpdir)`

`html2epub`で出来上がったepubを手直しするために`tmpdir`へ解凍します．epub3はzipファイルであることは割と有名だと思います．

### `make_epub(filename, tmpdir)`

手直しの終わったファイル群をepubにまとめます（こちらを参考にしました：[ターミナルを使ってEPUBに圧縮する](http://jittodesign.org/epub-taminal02-148/)）．

### `make_epub_for_kindle(name, css, cover, convert=True)`

一連の過程をワンタッチにしたものです．カレントディレクトリにある`name.tex`をソースとして，最終的に`name.epub`ファイルを生成します．その過程で多くのファイルを生成するので，`name_tmpdir`というディレクトリを作成し，そこに生成されるファイルを出力する仕様になっています．`css`と`cover`のファイルは`name.tex`と同じディレクトリに配置してください．

## 手直しって？

一度pandocでepubを作ってから，解凍して*手直し*をし，再びepubにしています．この手直しで一体何をしているかというと，数式化した画像の高さ調整をしています．MathMLが対応しているのであればこの心配はいらないのですが，数式を画像化するとなると，幅または高さの調整が必要で，絶対指定（あるいは特に指定しない）をしてしまうと，epubビューアでフォントサイズを変更しても数式の大きさが変わらないという事態になります．これを回避するためには，

```html
<img src="math001.png" style='height: 1.0em;'>
```

という風に，`style`属性で相対指定をするしかないと思われます（他にもっといい方法があったら是非教えてください）．その部分を設定しているコードを抜粋します：

```python
for img in xhtml.xpath('//xhtml:img', namespaces=ns):
    height = int(img.attrib['height'])
    img.attrib['style'] = 'height: ' + str(round(height/40, 2)) + 'em;'
```

具体的には，実際のpng画像の高さを40で割った値に単位`em`を付けています．場合によっては調節してください．


`html2epub()`の段階で，`style`属性を設定しておけば，わざわざ手直ししなくても済むのではないかと思うかもしれませんが，なんと，`pandoc`でhtmlをepubに変換すると，`img`要素の`style`属性が削除されてしまうというお節介仕様があります．

まあ，そうは言っても，他に`pandoc`が生成した`epub`を手直しする要素は意外とあるので，この解凍して圧縮するという作業は無駄ではないと思います．例えば，`html`要素に`lang`属性や`epub:lang`属性を付加するのはやはりこの手直しのタイミングでしかありません．他にも`section`要素の`id`属性など，（人によっては）手直しした方が良い要素は結構あります．

## stylesheet.css

`class`属性が`math display`のものは，`display: block`が必須でしょう．

```css:stylesheet.css
img.math.display{
    display: block;
    margin: .5em auto; /*中央寄せ*/
}

img.math.inline{
    margin-left: .2em;
    margin-right: .2em;
}
```

余白は好みです．その他，見出しや行間などは各自で調整してください．

## サンプル

```latex:sample.tex
\documentclass[uplatex]{jsbook}
\usepackage{amsmath,amssymb}
\begin{document}
\title{数の体系}

\chapter{自然数}

無限公理で保証されている集合で，もっとも小さいものを$\omega$とすると，$\emptyset\in\omega$であり，任意の$x\in\omega$に対して
\begin{align*}
  \sigma: x\mapsto x\cup\{x\}
\end{align*}
とすることで，$\omega$から$\omega$への写像が定義される．

\section{ペアノの公理系}

$(\omega, \emptyset, \sigma)$ はいわゆるペアノの公理系を満足する．

\chapter{整数}

2つの自然数$m, n$ に対して， 対$(m, n)$を整数の$m-n$とみなす，というのが整数構成のヒントである．

\section{同値関係}

$(1, 0)$と$(2, 1)$は同じ整数とみるので，同値関係が必要になる．

\end{document}
```


```python
make_epub_for_kindle('sample', 'stylesheet.css', 'cover.png')
```

<img width="792" alt="スクリーンショット 2017-09-17 21.12.48.png" src="https://qiita-image-store.s3.amazonaws.com/0/191414/018993a9-f14c-4f29-99a3-16250cf43dec.png">


## おそらく

今回はTeXファイルをベースにしましたが，流行りのマークダウンからでもいけそうです．ただ，画像化するのにTeXを使うので，TeX環境は必須でしょう．