From d15f4a340ecc8afeaaa98c9a17a98148f221d186 Mon Sep 17 00:00:00 2001 From: =?utf8?q?Vladim=C3=ADr=20Vondru=C5=A1?= Date: Tue, 12 Sep 2017 21:56:50 +0200 Subject: [PATCH] plugins: new m.math plugin. --- pelican-plugins/m/latex2svg.py | 182 +++++++++++++++++++++++++++++++++ pelican-plugins/m/math.py | 88 ++++++++++++++++ 2 files changed, 270 insertions(+) create mode 100644 pelican-plugins/m/latex2svg.py create mode 100644 pelican-plugins/m/math.py diff --git a/pelican-plugins/m/latex2svg.py b/pelican-plugins/m/latex2svg.py new file mode 100644 index 00000000..92e223bc --- /dev/null +++ b/pelican-plugins/m/latex2svg.py @@ -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 index 00000000..74181893 --- /dev/null +++ b/pelican-plugins/m/math.py @@ -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'\?> + +.+) xmlns='http://www.w3.org/2000/svg' xmlns:xlink='http://www.w3.org/1999/xlink'> +""") + +uncrap_dst = r"""> +LaTeX Math + +{formula} + +""" + +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 element instead of some outer + 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) -- 2.30.2