diff --git a/IPython/core/interactiveshell.py b/IPython/core/interactiveshell.py index 173cca1..b237df6 100644 --- a/IPython/core/interactiveshell.py +++ b/IPython/core/interactiveshell.py @@ -66,6 +66,7 @@ from IPython.core.prefilter import PrefilterManager from IPython.core.profiledir import ProfileDir from IPython.core.pylabtools import pylab_activate from IPython.core.prompts import PromptManager +from IPython.lib.latextools import LaTeXTool from IPython.utils import PyColorize from IPython.utils import io from IPython.utils import py3compat @@ -497,6 +498,7 @@ class InteractiveShell(SingletonConfigurable): self.init_display_pub() self.init_displayhook() self.init_reload_doctest() + self.init_latextool() self.init_magics() self.init_logstart() self.init_pdb() @@ -689,7 +691,13 @@ class InteractiveShell(SingletonConfigurable): doctest_reload() except ImportError: warn("doctest module does not exist.") - + + def init_latextool(self): + """Configure LaTeXTool.""" + cfg = LaTeXTool.instance(config=self.config) + if cfg not in self.configurables: + self.configurables.append(cfg) + def init_virtualenv(self): """Add a virtualenv to sys.path so the user can import modules from it. This isn't perfect: it doesn't use the Python interpreter with which the diff --git a/IPython/extensions/sympyprinting.py b/IPython/extensions/sympyprinting.py index c0bd6c0..409ebd7 100644 --- a/IPython/extensions/sympyprinting.py +++ b/IPython/extensions/sympyprinting.py @@ -65,7 +65,7 @@ def print_display_png(o): s = s.strip('$') # As matplotlib does not support display style, dvipng backend is # used here. - png = latex_to_png('$$%s$$' % s, backend='dvipng') + png = latex_to_png(s, backend='dvipng', wrap=True) return png diff --git a/IPython/lib/latextools.py b/IPython/lib/latextools.py index 1d40746..e009bfb 100644 --- a/IPython/lib/latextools.py +++ b/IPython/lib/latextools.py @@ -25,13 +25,50 @@ import shutil import subprocess from IPython.utils.process import find_cmd, FindCmdError +from IPython.config.configurable import SingletonConfigurable +from IPython.utils.traitlets import Instance, List, CBool, CUnicode #----------------------------------------------------------------------------- # Tools #----------------------------------------------------------------------------- -def latex_to_png(s, encode=False, backend='mpl'): +class LaTeXTool(SingletonConfigurable): + """An object to store configuration of the LaTeX tool.""" + + backends = List( + CUnicode, ["matplotlib", "dvipng"], + help="Preferred backend to draw LaTeX math equations. " + "Backends in the list are checked one by one and the first " + "usable one is used. Note that `matplotlib` backend " + "is usable only for inline style equations. To draw " + "display style equations, `dvipng` backend must be specified. ", + # It is a List instead of Enum, to make configuration more + # flexible. For example, to use matplotlib mainly but dvipng + # for display style, the default ["matplotlib", "dvipng"] can + # be used. To NOT use dvipng so that other repr such as + # unicode pretty printing is used, you can use ["matplotlib"]. + config=True) + + use_breqn = CBool( + True, + help="Use breqn.sty to automatically break long equations. " + "This configuration takes effect only for dvipng backend.", + config=True) + + packages = List( + ['amsmath', 'amsthm', 'amssymb', 'bm'], + help="A list of packages to use for dvipng backend. " + "'breqn' will be automatically appended when use_breqn=True.", + config=True) + + preamble = CUnicode( + help="Additional preamble to use when generating LaTeX source " + "for dvipng backend.", + config=True) + + +def latex_to_png(s, encode=False, backend=None, wrap=False): """Render a LaTeX string to PNG. Parameters @@ -40,37 +77,46 @@ def latex_to_png(s, encode=False, backend='mpl'): The raw string containing valid inline LaTeX. encode : bool, optional Should the PNG data bebase64 encoded to make it JSON'able. - backend : {mpl, dvipng} + backend : {matplotlib, dvipng} Backend for producing PNG data. + wrap : bool + If true, Automatically wrap `s` as a LaTeX equation. None is returned when the backend cannot be used. """ - if backend == 'mpl': + allowed_backends = LaTeXTool.instance().backends + if backend is None: + backend = allowed_backends[0] + if backend not in allowed_backends: + return None + if backend == 'matplotlib': f = latex_to_png_mpl elif backend == 'dvipng': f = latex_to_png_dvipng else: raise ValueError('No such backend {0}'.format(backend)) - bin_data = f(s) + bin_data = f(s, wrap) if encode and bin_data: bin_data = encodestring(bin_data) return bin_data -def latex_to_png_mpl(s): +def latex_to_png_mpl(s, wrap): try: from matplotlib import mathtext except ImportError: return None - + + if wrap: + s = '${0}$'.format(s) mt = mathtext.MathTextParser('bitmap') f = StringIO() mt.to_png(f, s, fontsize=12) return f.getvalue() -def latex_to_png_dvipng(s): +def latex_to_png_dvipng(s, wrap): try: find_cmd('latex') find_cmd('dvipng') @@ -83,9 +129,7 @@ def latex_to_png_dvipng(s): outfile = os.path.join(workdir, "tmp.png") with open(tmpfile, "w") as f: - f.write(_latex_header) - f.write(s) - f.write(_latex_footer) + f.writelines(genelatex(s, wrap)) subprocess.check_call( ["latex", "-halt-on-errror", tmpfile], cwd=workdir, @@ -103,17 +147,42 @@ def latex_to_png_dvipng(s): return bin_data -_latex_header = r''' -\documentclass{article} -\usepackage{amsmath} -\usepackage{amsthm} -\usepackage{amssymb} -\usepackage{bm} -\pagestyle{empty} -\begin{document} -''' - -_latex_footer = r'\end{document}' +def kpsewhich(filename): + """Invoke kpsewhich command with an argument `filename`.""" + try: + find_cmd("kpsewhich") + proc = subprocess.Popen( + ["kpsewhich", filename], + stdout=subprocess.PIPE, stderr=subprocess.PIPE) + (stdout, stderr) = proc.communicate() + return stdout.strip() + except FindCmdError: + pass + + +def genelatex(body, wrap): + """Generate LaTeX document for dvipng backend.""" + lt = LaTeXTool.instance() + breqn = wrap and lt.use_breqn and kpsewhich("breqn.sty") + yield r'\documentclass{article}' + packages = lt.packages + if breqn: + packages = packages + ['breqn'] + for pack in packages: + yield r'\usepackage{{{0}}}'.format(pack) + yield r'\pagestyle{empty}' + if lt.preamble: + yield lt.preamble + yield r'\begin{document}' + if breqn: + yield r'\begin{dmath*}' + yield body + yield r'\end{dmath*}' + elif wrap: + yield '$${0}$$'.format(body) + else: + yield body + yield r'\end{document}' _data_uri_template_png = """%s""" diff --git a/IPython/lib/tests/test_latextools.py b/IPython/lib/tests/test_latextools.py new file mode 100644 index 0000000..a06105c --- /dev/null +++ b/IPython/lib/tests/test_latextools.py @@ -0,0 +1,119 @@ +# encoding: utf-8 +"""Tests for IPython.utils.path.py""" + +#----------------------------------------------------------------------------- +# Copyright (C) 2008-2011 The IPython Development Team +# +# Distributed under the terms of the BSD License. The full license is in +# the file COPYING, distributed as part of this software. +#----------------------------------------------------------------------------- + +import nose.tools as nt + +from IPython.lib import latextools +from IPython.testing.decorators import onlyif_cmds_exist +from IPython.testing.tools import monkeypatch +from IPython.utils.process import FindCmdError + + +def test_latex_to_png_dvipng_fails_when_no_cmd(): + """ + `latex_to_png_dvipng` should return None when there is no required command + """ + for command in ['latex', 'dvipng']: + yield (check_latex_to_png_dvipng_fails_when_no_cmd, command) + + +def check_latex_to_png_dvipng_fails_when_no_cmd(command): + def mock_find_cmd(arg): + if arg == command: + raise FindCmdError + + with monkeypatch(latextools, "find_cmd", mock_find_cmd): + nt.assert_equals(latextools.latex_to_png_dvipng("whatever", True), + None) + + +@onlyif_cmds_exist('latex', 'dvipng') +def test_latex_to_png_dvipng_runs(): + """ + Test that latex_to_png_dvipng just runs without error. + """ + def mock_kpsewhich(filename): + nt.assert_equals(filename, "breqn.sty") + return None + + for (s, wrap) in [("$$x^2$$", False), ("x^2", True)]: + yield (latextools.latex_to_png_dvipng, s, wrap) + + with monkeypatch(latextools, "kpsewhich", mock_kpsewhich): + yield (latextools.latex_to_png_dvipng, s, wrap) + + +def test_genelatex_no_wrap(): + """ + Test genelatex with wrap=False. + """ + def mock_kpsewhich(filename): + assert False, ("kpsewhich should not be called " + "(called with {0})".format(filename)) + + with monkeypatch(latextools, "kpsewhich", mock_kpsewhich): + nt.assert_equals( + '\n'.join(latextools.genelatex("body text", False)), + r'''\documentclass{article} +\usepackage{amsmath} +\usepackage{amsthm} +\usepackage{amssymb} +\usepackage{bm} +\pagestyle{empty} +\begin{document} +body text +\end{document}''') + + +def test_genelatex_wrap_with_breqn(): + """ + Test genelatex with wrap=True for the case breqn.sty is installed. + """ + def mock_kpsewhich(filename): + nt.assert_equals(filename, "breqn.sty") + return "path/to/breqn.sty" + + with monkeypatch(latextools, "kpsewhich", mock_kpsewhich): + nt.assert_equals( + '\n'.join(latextools.genelatex("x^2", True)), + r'''\documentclass{article} +\usepackage{amsmath} +\usepackage{amsthm} +\usepackage{amssymb} +\usepackage{bm} +\usepackage{breqn} +\pagestyle{empty} +\begin{document} +\begin{dmath*} +x^2 +\end{dmath*} +\end{document}''') + + +def test_genelatex_wrap_without_breqn(): + """ + Test genelatex with wrap=True for the case breqn.sty is not installed. + """ + def mock_kpsewhich(filename): + nt.assert_equals(filename, "breqn.sty") + return None + + with monkeypatch(latextools, "kpsewhich", mock_kpsewhich): + nt.assert_equals( + '\n'.join(latextools.genelatex("x^2", True)), + r'''\documentclass{article} +\usepackage{amsmath} +\usepackage{amsthm} +\usepackage{amssymb} +\usepackage{bm} +\pagestyle{empty} +\begin{document} +$$x^2$$ +\end{document}''') diff --git a/IPython/testing/decorators.py b/IPython/testing/decorators.py index d03b068..83ef955 100644 --- a/IPython/testing/decorators.py +++ b/IPython/testing/decorators.py @@ -72,6 +72,9 @@ from ipunittest import ipdoctest, ipdocstring # numpy.testing.decorators, we expose all of it here. from IPython.external.decorators import * +# For onlyif_cmd_exists decorator +from IPython.utils.process import is_cmd_found + #----------------------------------------------------------------------------- # Classes and functions #----------------------------------------------------------------------------- @@ -342,3 +345,14 @@ else: onlyif_unicode_paths = onlyif(unicode_paths, ("This test is only applicable " "where we can use unicode in filenames.")) + + +def onlyif_cmds_exist(*commands): + """ + Decorator to skip test when at least one of `commands` is not found. + """ + for cmd in commands: + if not is_cmd_found(cmd): + return skip("This test runs only if command '{0}' " + "is installed".format(cmd)) + return null_deco diff --git a/IPython/testing/tools.py b/IPython/testing/tools.py index a104035..79ca7f3 100644 --- a/IPython/testing/tools.py +++ b/IPython/testing/tools.py @@ -369,3 +369,14 @@ def make_tempfile(name): yield finally: os.unlink(name) + + +@contextmanager +def monkeypatch(obj, name, attr): + """ + Context manager to replace attribute named `name` in `obj` with `attr`. + """ + orig = getattr(obj, name) + setattr(obj, name, attr) + yield + setattr(obj, name, orig) diff --git a/IPython/utils/process.py b/IPython/utils/process.py index 637914a..a5ae2a7 100644 --- a/IPython/utils/process.py +++ b/IPython/utils/process.py @@ -72,6 +72,15 @@ def find_cmd(cmd): return os.path.abspath(path) +def is_cmd_found(cmd): + """Check whether executable `cmd` exists or not and return a bool.""" + try: + find_cmd(cmd) + return True + except FindCmdError: + return False + + def pycmd2argv(cmd): r"""Take the path of a python command and return a list (argv-style).