--- /dev/null
+#!/usr/bin/env python3
+"""latex2svg
+
+Read LaTeX code from stdin and render a SVG using LaTeX + dvisvgm.
+"""
+__version__ = '0.1.0'
+__author__ = 'Tino Wagner'
+__email__ = 'ich@tinowagner.com'
+__license__ = 'MIT'
+__copyright__ = '(c) 2017, Tino Wagner'
+
+import os
+import sys
+import subprocess
+import shlex
+import re
+from tempfile import TemporaryDirectory
+from ctypes.util import find_library
+
+default_template = r"""
+\documentclass[{{ fontsize }}pt,preview]{standalone}
+{{ preamble }}
+\begin{document}
+\begin{preview}
+{{ code }}
+\end{preview}
+\end{document}
+"""
+
+default_preamble = r"""
+\usepackage[utf8x]{inputenc}
+\usepackage{amsmath}
+\usepackage{amsfonts}
+\usepackage{amssymb}
+\usepackage{newtxtext}
+\usepackage[libertine]{newtxmath}
+"""
+
+latex_cmd = 'latex -interaction nonstopmode -halt-on-error'
+dvisvgm_cmd = 'dvisvgm --no-fonts'
+
+default_params = {
+ 'fontsize': 12, # pt
+ 'template': default_template,
+ 'preamble': default_preamble,
+ 'latex_cmd': latex_cmd,
+ 'dvisvgm_cmd': dvisvgm_cmd,
+ 'libgs': None,
+}
+
+
+if not hasattr(os.environ, 'LIBGS') and not find_library('libgs'):
+ if sys.platform == 'darwin':
+ # Fallback to homebrew Ghostscript on macOS
+ homebrew_libgs = '/usr/local/opt/ghostscript/lib/libgs.dylib'
+ if os.path.exists(homebrew_libgs):
+ default_params['libgs'] = homebrew_libgs
+ if not default_params['libgs']:
+ print('Warning: libgs not found')
+
+
+def latex2svg(code, params=default_params, working_directory=None):
+ """Convert LaTeX to SVG using dvisvgm.
+
+ Parameters
+ ----------
+ code : str
+ LaTeX code to render.
+ params : dict
+ Conversion parameters.
+ working_directory : str or None
+ Working directory for external commands and place for temporary files.
+
+ Returns
+ -------
+ dict
+ Dictionary of SVG output and output information:
+
+ * `svg`: SVG data
+ * `width`: image width in *em*
+ * `height`: image height in *em*
+ * `depth`: baseline position in *em*
+ """
+ if working_directory is None:
+ with TemporaryDirectory() as tmpdir:
+ return latex2svg(code, params, working_directory=tmpdir)
+
+ fontsize = params['fontsize']
+ document = (params['template']
+ .replace('{{ preamble }}', params['preamble'])
+ .replace('{{ fontsize }}', str(fontsize))
+ .replace('{{ code }}', code))
+
+ with open(os.path.join(working_directory, 'code.tex'), 'w') as f:
+ f.write(document)
+
+ # Run LaTeX and create DVI file
+ try:
+ ret = subprocess.run(shlex.split(params['latex_cmd']+' code.tex'),
+ stdout=subprocess.PIPE, stderr=subprocess.PIPE,
+ cwd=working_directory)
+ ret.check_returncode()
+ except FileNotFoundError:
+ raise RuntimeError('latex not found')
+
+ # Add LIBGS to environment if supplied
+ env = os.environ.copy()
+ if params['libgs']:
+ env['LIBGS'] = params['libgs']
+
+ # Convert DVI to SVG
+ try:
+ ret = subprocess.run(shlex.split(params['dvisvgm_cmd']+' code.dvi'),
+ stdout=subprocess.PIPE, stderr=subprocess.PIPE,
+ cwd=working_directory, env=env)
+ ret.check_returncode()
+ except FileNotFoundError:
+ raise RuntimeError('dvisvgm not found')
+
+ with open(os.path.join(working_directory, 'code.svg'), 'r') as f:
+ svg = f.read()
+
+ # Parse dvisvgm output for size and alignment
+ def get_size(output):
+ regex = r'\b([0-9.]+)pt x ([0-9.]+)pt'
+ match = re.search(regex, output)
+ if match:
+ return (float(match.group(1)) / fontsize,
+ float(match.group(2)) / fontsize)
+ else:
+ return None, None
+
+ def get_measure(output, name):
+ regex = r'\b%s=([0-9.]+)pt' % name
+ match = re.search(regex, output)
+ if match:
+ return float(match.group(1)) / fontsize
+ else:
+ return None
+
+ output = ret.stderr.decode('utf-8')
+ width, height = get_size(output)
+ depth = get_measure(output, 'depth')
+ return {'svg': svg, 'depth': depth, 'width': width, 'height': height}
+
+
+def main():
+ """Simple command line interface to latex2svg.
+
+ - Read from `stdin`.
+ - Write SVG to `stdout`.
+ - Write metadata as JSON to `stderr`.
+ - On error: write error messages to `stdout` and return with error code.
+ """
+ import json
+ import argparse
+ parser = argparse.ArgumentParser(description="""
+ Render LaTeX code from stdin as SVG to stdout. Writes metadata (baseline
+ position, width, height in em units) as JSON to stderr.
+ """)
+ parser.add_argument('--preamble',
+ help="LaTeX preamble code to read from file")
+ args = parser.parse_args()
+ preamble = default_preamble
+ if args.preamble is not None:
+ with open(args.preamble) as f:
+ preamble = f.read()
+ latex = sys.stdin.read()
+ try:
+ params = default_params.copy()
+ params['preamble'] = preamble
+ out = latex2svg(latex, params)
+ sys.stdout.write(out['svg'])
+ meta = {key: out[key] for key in out if key != 'svg'}
+ sys.stderr.write(json.dumps(meta))
+ except subprocess.CalledProcessError as exc:
+ print(exc.output.decode('utf-8'))
+ sys.exit(exc.returncode)
+
+
+if __name__ == '__main__':
+ main()
--- /dev/null
+import re
+
+from docutils import nodes
+from docutils.parsers import rst
+from docutils.parsers.rst import directives
+from docutils.parsers.rst.roles import set_classes
+
+from . import latex2svg
+
+latex2svg_params = latex2svg.default_params.copy()
+latex2svg_params.update({
+ # Don't use libertine fonts as they mess up things
+ 'preamble': r"""
+\usepackage[utf8x]{inputenc}
+\usepackage{amsmath}
+\usepackage{amsfonts}
+\usepackage{amssymb}
+\usepackage{newtxtext}
+""",
+ # Zoom the letters a bit to match page font size
+ 'dvisvgm_cmd': 'dvisvgm --no-fonts -Z 1.25',
+ })
+
+uncrap_src = re.compile(r"""<\?xml version='1.0' encoding='UTF-8'\?>
+<!-- This file was generated by dvisvgm \d+\.\d+\.\d+ -->
+<svg (?P<attribs>.+) xmlns='http://www.w3.org/2000/svg' xmlns:xlink='http://www.w3.org/1999/xlink'>
+""")
+
+uncrap_dst = r"""<svg{attribs} \g<attribs>>
+<title>LaTeX Math</title>
+<description>
+{formula}
+</description>
+"""
+
+class Math(rst.Directive):
+ option_spec = {'class': directives.class_option,
+ 'name': directives.unchanged}
+ has_content = True
+
+ def run(self):
+ set_classes(self.options)
+ self.assert_has_content()
+ # join lines, separate blocks
+ content = '\n'.join(self.content).split('\n\n')
+ _nodes = []
+ for block in content:
+ if not block:
+ continue
+
+ container = nodes.container(**self.options)
+ container['classes'] += ['m-math']
+ node = nodes.raw(self.block_text, uncrap_src.sub(
+ uncrap_dst.format(attribs='', formula=block.replace('\\', '\\\\')),
+ latex2svg.latex2svg("$$" + block + "$$", params=latex2svg_params)['svg']), format='html')
+ node.line = self.content_offset + 1
+ self.add_name(node)
+ container.append(node)
+ _nodes.append(container)
+ return _nodes
+
+def math(role, rawtext, text, lineno, inliner, options={}, content=[]):
+ # Otherwise the backslashes do quite a mess there
+ i = rawtext.find('`')
+ text = rawtext.split('`')[1]
+
+ # Apply classes to the <svg> element instead of some outer <span>
+ set_classes(options)
+ classes = 'm-math'
+ if 'classes' in options:
+ classes += ' ' + ' '.join(options['classes'])
+ del options['classes']
+
+ out = latex2svg.latex2svg("$" + text + "$", params=latex2svg_params);
+
+ # CSS classes and styling for proper vertical alignment. Depth is relative
+ # to font size, describes how below the line the text is. Scaling it back
+ # to 12pt font, scaled by 125% as set above in the config.
+ attribs = ' class="{}" style="vertical-align: -{:.1f}pt;"'.format(classes, out['depth']*12*1.25)
+
+ node = nodes.raw(rawtext, uncrap_src.sub(
+ uncrap_dst.format(attribs=attribs, formula=text.replace('\\', '\\\\')),
+ out['svg']), format='html', **options)
+ return [node], []
+
+def register():
+ rst.directives.register_directive('math', Math)
+ rst.roles.register_canonical_role('math', math)