Edited at

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

More than 1 year has passed since last update.

楽天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)


とりあえずコード

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 オプションによって,数式の部分は

<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文書内の

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

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

<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に圧縮する).


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

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


手直しって?

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

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

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

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が必須でしょう.


stylesheet.css

img.math.display{

display: block;
margin: .5em auto; /*中央寄せ*/
}

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


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


サンプル


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}


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

スクリーンショット 2017-09-17 21.12.48.png


おそらく

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