chiark / gitweb /
plugins: new m.math plugin.
authorVladimír Vondruš <mosra@centrum.cz>
Tue, 12 Sep 2017 19:56:50 +0000 (21:56 +0200)
committerVladimír Vondruš <mosra@centrum.cz>
Thu, 14 Sep 2017 22:11:11 +0000 (00:11 +0200)
pelican-plugins/m/latex2svg.py [new file with mode: 0644]
pelican-plugins/m/math.py [new file with mode: 0644]

diff --git a/pelican-plugins/m/latex2svg.py b/pelican-plugins/m/latex2svg.py
new file mode 100644 (file)
index 0000000..92e223b
--- /dev/null
@@ -0,0 +1,182 @@
+#!/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()
diff --git a/pelican-plugins/m/math.py b/pelican-plugins/m/math.py
new file mode 100644 (file)
index 0000000..7418189
--- /dev/null
@@ -0,0 +1,88 @@
+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)